Commit Your Secrets to Git, Encrypted, with SOPS and age
Here's a reflex almost every project I've ever touched has: create a .env, fill it with the database password and the API keys, then immediately add it to .gitignore so it never lands in the repo. Done. Safe. Move on.
Except it's not really safe, it's just absent. I went looking for something better a while back and landed on git-crypt, which at least put the secrets back in the repo. It worked, but it always felt a bit off (your encrypted files turn into opaque binary blobs, and the diffs become useless). So I kept looking, and these days I keep secrets inside Git with two small tools that do the job much better: SOPS and age. If you're a homelabber who doesn't want (or need) to stand up a whole secrets-management stack just to hide a database password, this is the writeup for you.
The problem: .gitignore doesn't solve anything, it just hides it
The moment you put .env in .gitignore, you've quietly pulled one piece of your configuration out of the system that versions everything else. And that one piece happens to be the most sensitive one. Look at what you've actually signed up for:
- No history. Who changed the SMTP password last month, and to what? No idea, it's not in Git.
- No review. A secret rotation never shows up in a pull request, so nobody ever looks at it.
- Manual sharing. A new teammate spins up the project and… pings you on Slack for the
.env. You paste it. It's now in a chat log forever. - Drift. Your
.env, mine, and the server's have slowly diverged, and nobody knows which one is "right."
The real issue isn't "secrets are dangerous." It's that we took the one file that matters most and exiled it from version control. What I actually want is the opposite: the secret living right next to the code, versioned and reviewable like everything else, but unreadable to anyone without the key.
Why not just… something else?
I looked at the usual suspects before settling. The short version, in trade-off-table form:
| Approach | The catch |
|---|---|
.gitignore + share by hand | No history, no review, "who has the latest version?" |
| git-crypt | What I used before. Encrypts whole files as binary blobs, so your diffs become unreadable, and the project feels half-abandoned. |
| Vault / a cloud Secrets Manager | Genuinely great, and a whole piece of infrastructure to run and pay for. Overkill for a homelab or a small team. |
| SOPS + age | Encrypts value by value, keeps the file structure readable, keys are trivial, and there's zero server to run. |
That last row is the one that won me over. No daemon, no service, no vendor, just two binaries and a couple of key files. Very much the same "self-hosted, it's yours" philosophy I keep coming back to.
age, in about 30 seconds
age (the tool, lowercase, by Filippo Valsorda) is modern file encryption that does one thing well. If you've ever fought with GPG (keyrings, expired subkeys, the web of trust, that arcane CLI), age is the antidote. A key is two lines of text. There's no keyring to manage and no trust ceremony. You encrypt to a public key, you decrypt with the private one. That's the whole mental model.
SOPS knows how to use several backends (GPG, AWS KMS, GCP, Vault…), but age is by far the nicest to live with when you don't have a cloud KMS in the loop. So that's the pairing here.
The one thing people get wrong: public vs private key
This tripped me up at first, so let's nail it down before anything else, because it's the mistake to avoid.
You generate a key like this:
$ age-keygen -o key.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8j
$ cat key.txt
# created: 2026-06-18T10:00:00Z
# public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8j
AGE-SECRET-KEY-1GFPYYSJZGFPYYSJZGFPYYSJZGFPYYSJZGFPYYSJZGFPYYSJZGFPYQ0RFEER
That key.txt file is your private key (the AGE-SECRET-KEY-… line). It's the secret. It never, ever goes into Git. The public key (the age1… string, also printed as a comment at the top of the file) is the recipient, that's what you hand out and commit.
| Thing | What it is | Goes in Git? |
|---|---|---|
key.txt (private) | AGE-SECRET-KEY-… | ❌ Never, keep it in a password manager / CI secret |
public key (age1…) | the recipient | ✅ Yes, it lives in .sops.yaml |
And that asymmetry is the whole point: you encrypt with the public key, which you can paste anywhere (in config, in a README, on a sticky note), and you can only decrypt with the private one, which stays locked away. Exposing the public key is harmless by design.
What SOPS actually does
Here's the clever bit. SOPS doesn't encrypt the whole file, it encrypts the values, not the keys of a YAML, JSON, or .env file. So the structure stays perfectly readable in Git, and your diffs still tell you which secret changed, just not what it changed to.
secrets.yaml (plaintext) secrets.enc.yaml (committed)
------------------------- -----------------------------
db_password: hunter2 --SOPS--> db_password: ENC[AES256_GCM,data:7Hk2..,tag:..]
api_key: sk_live_abc123 api_key: ENC[AES256_GCM,data:9Qz1..,tag:..]
sops:
age:
- recipient: age1ql3z7...
enc: |-
-----BEGIN AGE ENCRYPTED FILE-----
You can still git diff a change and see that db_password was touched. Code review survives. The value is gibberish to anyone without the private key. This is the thing git-crypt can't give you.
A minimal setup
Four steps from nothing to a secret safely sitting in your repo.
1. Generate your key (once, per person or per machine):
$ age-keygen -o key.txt
Grab the age1… public key it prints. Stash key.txt somewhere safe, like your password manager, or ~/.config/sops/age/keys.txt where SOPS looks by default.
2. Tell SOPS who can decrypt, in a .sops.yaml at the repo root:
creation_rules:
- path_regex: \.enc\.ya?ml$
age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8j
This says: "any file ending in .enc.yaml gets encrypted to this recipient." This file is committed, it only contains public keys, and it's the source of truth for who can read your secrets.
3. Create and edit secrets:
$ sops secrets.enc.yaml
SOPS opens your $EDITOR with the file in plaintext. You type your secrets like a normal YAML file, save, quit, and SOPS encrypts the values on the way out, automatically, using the rule it matched. You never touch the encryption commands by hand.
4. Commit it without flinching:
$ git add secrets.enc.yaml .sops.yaml
$ git commit -m "Add encrypted secrets"
That's it. Your secret is now in version control, with full history and reviewable diffs, and it's useless to anyone who clones the repo without the private key.
Using it at deploy time (docker-compose)
Committing the secret is only half the story, at some point the app needs the cleartext. The trick is to decrypt at the last possible moment, in memory, and never write the plaintext back to disk.
My go-to is sops exec-env, which decrypts a file, injects the values as environment variables into a child process, and tears them down when it exits:
$ sops exec-env secrets.enc.yaml 'docker compose up -d'
Everything in secrets.enc.yaml becomes an env var for the duration of that docker compose call, which your compose.yaml can then reference:
services:
app:
image: my-app:latest
environment:
DB_PASSWORD: ${db_password}
API_KEY: ${api_key}
On the server, SOPS finds the private key via SOPS_AGE_KEY_FILE (or the default location), decrypts, runs the command, and the plaintext never lands in a file. If you'd rather have a plain .env for some tool that insists on one, sops -d secrets.enc.yaml > .env works too, just make sure that .env is the one thing you do gitignore.
Things to watch out for
- The private key never goes in Git. This is the cardinal rule. It lives in a password manager, in
~/.config/sops/age/, or as a CI secret, anywhere but the repo. - Rotation is a config change. Add or remove recipients in
.sops.yaml, then runsops updatekeys secrets.enc.yamlto re-encrypt the existing data to the new set. No need to re-type the secrets. - Multiple recipients mean built-in collaboration. List several
age1…keys and each person (or each machine, or your CI) can decrypt with their own private key. No shared master secret to pass around, which quietly solves the "ping me for the .env" problem from the top of this post. - Watch your editor. SOPS edits in memory, but some editors leave swap files or backups. And resist the urge to keep a decrypted copy lying around "just for now."
- Commit
.sops.yaml. It defines who can decrypt. If it's missing, SOPS won't know how to encrypt new files, and you lose the audit trail of who has access.
Final thoughts
The mental shift that made this click for me: a secret isn't a second-class citizen you have to smuggle out of the repo. It's just configuration that happens to be sensitive. With SOPS and age it lives with the code, versioned, reviewable, shareable, and it's plain gibberish without the key.
And that's really the sweet spot here. If you're a homelabber, or running a small project, and you don't want (or need) to stand up Vault and a whole secrets-management stack just to hide a database password, this is exactly the right amount of tooling. No server to run, no vendor to depend on, no keyring to babysit. Two binaries and a couple of key files, and the most sensitive part of your project finally rejoins the system that versions everything else. After years of the .gitignore reflex (and a detour through git-crypt), that feels like the obvious place it should have been all along.