Every developer learns it on day one: never commit .env to git. The companies that learned it the hard way paid for the lesson — AWS keys scraped from public repos, customer data exposed because someone pushed credentials by mistake. The rule exists for good reason.
But the rule is more precise than it first looks. It’s not never commit secrets to git — it’s never commit plaintext secrets to git. Once you separate those two clauses, a different question opens up: what if you could encrypt your .env file, commit it, and have it automatically decrypted into your shell the moment you cd into the directory?
Turns out you can. And for solo projects and small teams, it’s probably the best place those secrets could live.
The pain you already know
If you’ve worked on more than one machine, on more than one project, you’ve felt this:
- A
.env.localon your laptop that doesn’t quite match the one on your desktop. - A new collaborator gets onboarded by you DM-ing them values one at a time over Slack.
- Someone rotates a Stripe key and three services start failing because two of them never got the update.
- You set up a fresh laptop and waste an hour treasure-hunting for the right values across Bitwarden, old
.envbackups, and 1Password notes.
The standard answer is “use a secret manager.” Doppler, 1Password CLI, HashiCorp Vault, AWS Secrets Manager, Bitwarden Secrets Manager. They all work. They’re all good products. But they share three properties that aren’t always great:
- They cost money — usually per-seat. Cheap when it’s just you, less cheap as a team grows.
- They’re a SaaS dependency — if your provider has an outage, your app might not start.
- They’re a second source of truth — code lives in git, secrets live in the manager, and you have to keep them in sync manually.
The first two are tradeoffs you might accept. The third is the one that quietly breaks things in production — when a PR adds a new env var and the migration to the secret manager doesn’t happen.
The trick: public-key crypto + git
There’s a small family of tools that’s been used by infrastructure teams for years but rarely shows up in app development:
- SOPS (Secrets OPerationS) — an encryption tool originally from Mozilla, now part of the CNCF. It encrypts structured files (JSON, YAML, dotenv) with one of several key backends.
- age — a modern alternative to GPG, by Filippo Valsorda (former Go security lead at Google). Simple, fast, modern crypto.
- direnv — a shell extension that loads and unloads environment variables when you
cdinto and out of a directory.
The combination gives you this:
- You generate an
agekey pair. The public key goes in your repo (in a config file). The private key stays in your home directory (and a backup in your password manager). - You encrypt your
.envfile with the public key. The result is opaque ciphertext — and you commit it to git. direnvis configured to runsops --decrypt .env.localwhenever you enter the directory. The decryption happens in memory; the plaintext never lands on disk.- The decrypted values are exported as environment variables. Your
yarn dev,python manage.py,make build— anything you run from this shell — sees them as if they were normal env vars.
The asymmetric crypto matters here: anyone with the public key can encrypt new secrets (which means a teammate can update the file without ever seeing your private key). But only people with the private key can read them.

What it actually looks like
Three files do all the work.
.sops.yaml — tells SOPS which recipient(s) can decrypt:
creation_rules:
- path_regex: \.env(\..+)?$
encrypted_regex: "^(.*)$"
age: age1vpjywnnfvqy56g2g8cr9zmk5055t8x53qnlk7v0uuy6sjusgseaqhswlez
.envrc — tells direnv how to load the file:
watch_file .env.local
set -a
. <(sops -d .env.local)
set +a
.gitignore — the usual .env* block, with an exception:
.env*
!.env.local # SOPS-encrypted, safe to commit
!.envrc # direnv loader, no secrets inside
And .env.local itself, after encryption, looks like this:
{
"data": "ENC[AES256_GCM,data:2ze2NW9EQCDXgcQlyWsLCegV9OxDkmCkKUA57GYXD8...truncated...,iv:B4aFN8Jl9oljdZeR0/wO54klbi8ljPHxuc7HgYoYCA4=,tag:MNipGMVwGiS1A4nBX794pA==,type:str]",
"sops": {
"age": [{ "recipient": "age1vpjy...", "enc": "..." }],
"lastmodified": "2026-05-14T10:36:11Z",
"mac": "ENC[AES256_GCM,..."
}
}
Open this file in a text editor without the private key, and that’s all you’ll ever see. Push it to a public repo and a curious attacker gets the same view. The encryption is AES-256-GCM, which means brute-forcing it would take longer than the heat death of the universe.
The daily-life workflow
After the initial setup, you mostly forget the tooling exists.
Editing a secret:
sops .env.local
This opens the plaintext in your $EDITOR (vim, nano, VS Code, whatever you have configured). You change a value, save, exit — SOPS re-encrypts the file in place. git diff shows a blob of changed ciphertext, which is exactly what you want.
Adding a new secret on a feature branch:
sops .env.local # add STRIPE_WEBHOOK_SECRET=...
git add .env.local
git commit -m "feat: handle stripe webhooks (needs STRIPE_WEBHOOK_SECRET)"
The secret and the code that uses it ship in the same commit. Code review covers both. There’s no “did you remember to add this in Doppler?” conversation.
Pulling someone else’s secret rotation:
git pull
That’s it. direnv notices .env.local changed and re-decrypts on the next shell prompt. Your shell sees the new value. (You’ll still need to restart any running dev server to pick it up — that part is universal across every secret-management approach.)
Setting up a new machine:
brew install sops age direnv- Paste your
ageprivate key into~/Library/Application Support/sops/age/keys.txt(from your password manager) git clone …→cd …direnv allow .
About 30 seconds. No accounts to create, no invitations to wait for, no CLI to log into.
The killer feature: secrets travel with code
This is the part that’s hard to appreciate until you’ve lived with it. In every other secret-management setup I’ve used, secrets and code live in two separate worlds that you have to keep in sync manually. Git for code. Doppler / Vault / 1Password for secrets. Every new env var becomes a checklist item: did you remember to add this in the secret manager?
With encrypted secrets in git, that checklist disappears. A PR that introduces STRIPE_WEBHOOK_SECRET touches both the code that reads it and the encrypted file that holds it. git log shows when it was added. git blame shows by whom. git revert rolls back both atomically. The PR description has the full picture.
Onboarding is the same trick viewed from a different angle. You don’t tell a new contributor “here’s the repo, and oh also we use Doppler, you’ll need an account, here’s how to log in, here’s how to fetch secrets, make sure your local has them before you run anything.” You tell them “here’s the repo and here’s the age key — direnv allow and you’re done."
"But is it really safe?”
The first reaction when I show this to a developer is always some variant of “…wait, your secrets are in git?” Yes, but:
What if the repo leaks publicly? The repo could be public from day one and it wouldn’t change anything. The encrypted file is a chunk of ciphertext that’s useless without the private key. The same crypto protects your banking app, Signal messages, and AWS encrypted volumes.
What if I lose the age key? This is the real risk. If every copy of the key disappears — your laptop’s hard drive and your password-manager backup and every teammate’s machine — the secrets are unrecoverable from the encrypted file alone. You’d need to re-issue them from upstream (regenerate Supabase keys, rotate Stripe credentials, etc.). The backup discipline is non-optional; treat the age key the way you treat your password-manager master password.
What if the age key leaks? Rotate it. Generate a new key, update .sops.yaml with the new public key, run sops updatekeys .env.local to re-encrypt the data key for the new recipient. Old versions of the file in git history are still readable with the old key, so if you suspect a real compromise, you should also rotate the underlying secrets — re-keying alone doesn’t help once someone has already decrypted and exfiltrated the values.
Why don’t more app teams do this if it’s so good? Mostly historical accident. SOPS comes from the Kubernetes / GitOps world, where committing encrypted manifests has been standard for years. App developers tend to default to whatever their framework’s documentation suggests, which is usually either “use the platform’s built-in secret manager” or “sign up for a SaaS like Doppler.” The encrypted-in-git path exists; it just isn’t the path of least resistance.
When this fits — and when it doesn’t
Use SOPS + age + direnv when:
- You’re a solo developer or a small team (up to 3–4 people).
- You self-host — Dokploy, your own server, a VPS — rather than running on Vercel / Netlify with deep platform integrations.
- You don’t have a compliance officer asking for per-read access logs.
- You care about offline capability, minimal SaaS dependencies, or fixed (zero) cost.
Use a managed secret manager (Doppler, Vault, 1Password) when:
- Your team is growing beyond a handful of people.
- You need role-based access — juniors only see development secrets, admins see production.
- A compliance audit will ask who read which secret and when.
- Non-technical users (designers, PMs) need to view or edit secrets through a web UI.
- Your deployment platform has a native integration (Vercel + Doppler, for example) that handles rotation and propagation automatically.
The choice isn’t “free vs. paid.” It’s “git-centric vs. SaaS-centric.” Both are valid; they just optimize for different things.
A working setup, end to end
If you want to try this on a real project, here’s the minimal recipe:
brew install sops age direnv
echo 'eval "$(direnv hook zsh)"' >> ~/.zshrc # bash: replace zsh → bash
# generate the key pair
mkdir -p "$HOME/Library/Application Support/sops/age"
age-keygen -o "$HOME/Library/Application Support/sops/age/keys.txt"
chmod 600 "$HOME/Library/Application Support/sops/age/keys.txt"
# back up the private key NOW — paste it into your password manager
cat "$HOME/Library/Application Support/sops/age/keys.txt"
# in your repo
cd your-project
PUBKEY=$(age-keygen -y "$HOME/Library/Application Support/sops/age/keys.txt")
cat > .sops.yaml <<EOF
creation_rules:
- path_regex: \\.env(\\..+)?\$
encrypted_regex: "^(.*)\$"
age: $PUBKEY
EOF
cat > .envrc <<'EOF'
watch_file .env.local
set -a
. <(sops -d .env.local)
set +a
EOF
# encrypt your existing .env.local (or create a fresh one first)
sops -e -i .env.local
# adjust .gitignore
cat >> .gitignore <<'EOF'
.env*
!.env.local
!.envrc
EOF
direnv allow .
That’s the whole thing. From a fresh checkout, the same flow works in reverse: clone the repo, paste your age key into the right path, direnv allow, and you’re running.
Closing thought
Secret management doesn’t have to be a SaaS subscription. For solo projects and small teams, the boring answer — a file in your git repo, encrypted with a key you keep in your password manager — is often the most pleasant one to live with day to day. One source of truth. One git pull to sync everything. One key to back up.
The rule isn’t “never commit secrets to git.” The rule is “never commit plaintext secrets to git.” Once you internalize the difference, the repo turns out to be a perfectly good home.