Secrets Management With SOPS and age
Keep your passwords in git without keeping your passwords in plaintext

Contents
The moment you commit to managing your homelab as code, you hit the secrets problem within the hour. Your git repo describes every service beautifully — the compose files, the reverse-proxy config, the whole declarative lovely thing — and then one service needs a database password and the illusion cracks. You cannot commit the password to git, because git remembers forever and repos leak. You cannot leave it out, because then the repo doesn’t actually describe a working system. People solve this badly for years: passwords in a .env file that is gitignored and therefore backed up nowhere, or worse, committed “temporarily” and never scrubbed from history.
SOPS with age is the clean answer, and it has become the thing I reach for on every new project. It lets you commit your secrets to the same repo as everything else, encrypted, so the file that describes your service and the password it needs travel together and stay in sync. Decryption needs a key that lives on your machine and never touches the repo. Here is how it works, why the design is clever, and how to run it without shooting yourself in the foot.
Why not just encrypt the whole file
The naive approach is to encrypt the entire secrets file with something like gpg and commit the blob. That works, but it is miserable to live with. Every change means decrypt-the-whole-thing, edit, re-encrypt, and the git diff is one giant unreadable ciphertext line that changed completely. You lose the entire point of version control: seeing what changed and when.
SOPS — Secrets OPerationS, originally from Mozilla — does something smarter. It encrypts the values in a structured file while leaving the keys in plaintext. Take a YAML file with db_password: hunter2; SOPS turns it into db_password: ENC[AES256_GCM,data:...,tag:...] and leaves the key name db_password readable. The structure of the file stays legible, so a diff shows you that the database password changed without revealing what it changed to, and you can add a new secret without decrypting the old ones. That property alone is why SOPS beats whole-file encryption for anything you keep under version control.
age is the encryption backend doing the actual maths. It is a modern, deliberately small file-encryption tool — think of it as the good parts of GPG with none of the archaeology. An age keypair is two short strings: a public key you encrypt to, and a secret key you decrypt with. There are no web-of-trust ceremonies, no expiry subkey dances, no forty options. You can use SOPS with cloud KMS backends instead, but for a homelab age is the sweet spot — no cloud dependency, no bill, and a key you fully control.
Setting it up
Install both tools, then generate an age key. The key generation gives you a keypair; guard the private half like your life depends on it, because it decrypts everything.
| |
Now tell SOPS which public key to encrypt to, so you never have to type it. A .sops.yaml file at the repo root maps file paths to recipients:
| |
Two recipients there, deliberately: your working key and a recovery key kept offline. Now editing a secret is a single command, and SOPS handles the encrypt-decrypt round trip transparently in your editor:
| |
That opens the file decrypted in $EDITOR. You edit plaintext, save, and SOPS re-encrypts the values on write. The file that lands in git looks like this:
| |
The key names are readable, the values are ciphertext, and that mac at the bottom is a message authentication code over the whole file — if anyone tampers with the plaintext keys or reorders things, decryption fails loudly. Commit that, and your secret is safely in git.
Getting the plaintext back where it’s needed
Encrypting is half the job; the secret has to reach the service that needs it. For an interactive shell you decrypt to stdout:
| |
For automation, SOPS shines in a GitOps flow. If you run Kubernetes, the sops-nix, helm-secrets, or a SOPS-aware operator decrypts at deploy time using a key the cluster holds. In a plain docker compose setup, a small wrapper decrypts the secrets file to a tmpfs mount before bringing the stack up, so the plaintext exists only in RAM and vanishes on reboot. The pattern that matters is that the encrypted file lives in the repo and the decryption key lives only on the machines allowed to run the thing.
For CI, this is where the design pays off. Your build runner needs the age secret key to decrypt, and that one bootstrap secret goes into the CI system’s own secret store — the single credential that isn’t in the repo. Everything else rides along encrypted in git and gets decrypted by the runner at deploy time. This slots directly into a wider GitOps loop where config and secrets are versioned together, reviewed together, and rolled back together — and it addresses one of the exposures that a good homelab threat model always turns up, the plaintext credential sitting somewhere it shouldn’t.
One key per machine, not one key to rule them all
The tempting shortcut is a single age key shared across every machine and every teammate. Resist it. The whole benefit of the recipient list is that you can grant and revoke access per key, and that only works if keys map to something meaningful. My rule of thumb is one key per identity that decrypts: one for me, one for each server or cluster that needs to read secrets at runtime, and one offline recovery key that lives on an encrypted stick and never touches a networked box.
.sops.yaml makes this scale without pain, because you can point different paths at different recipient sets. Production database secrets get encrypted to the production cluster’s key and your own; a staging file gets staging’s key. A single misconfigured server can then only decrypt the secrets it was actually granted:
| |
The recovery key on that offline stick is the piece people skip and later regret. The failure it guards against is mundane and total: your laptop dies, and with it the only key that decrypts your entire secret store. An offline recovery recipient added to every file means a dead laptop is an annoyance rather than the loss of every password you have. Generate it once, add it everywhere, and put the private half somewhere a house fire and a burglar would both have to work for.
Rotating keys without a crisis
Secrets rot. Someone leaves, a laptop is lost, a key is a few years old and you want a fresh one. SOPS makes rotation bearable because the recipient list lives in the file. To add or remove a key, you re-encrypt with the new recipient set:
| |
That reads your current .sops.yaml recipients and re-wraps the data key for the new set, without you ever seeing the plaintext values. Change the recipients in .sops.yaml, run updatekeys across your secret files, commit. Anyone whose key you dropped can no longer decrypt new commits.
The blunt truth about rotation is that a compromised secret is compromised the moment it leaks, and re-encrypting the git file does not un-leak it. If a private key or a password gets out, you rotate the actual credential — change the database password, revoke the token — and then update the encrypted file. SOPS manages access to the store; it cannot reach into every service and change the password for you.
Troubleshooting
“no matching creation rules found”. SOPS created a file with no recipients because your .sops.yaml path_regex didn’t match the path you used. The regex matches against the path as you typed it, so sops secrets/foo.yaml and sops ./secrets/foo.yaml can behave differently. Anchor your regex loosely and test with sops --verbose.
“Failed to get the data key required to decrypt”. The private key SOPS needs isn’t where it is looking. SOPS finds age keys via SOPS_AGE_KEY_FILE, or ~/.config/sops/age/keys.txt by default. On a server or CI runner, that variable is almost always the missing piece. Confirm the file is present and readable by the right user.
The file decrypts locally but not in CI. The runner has a different key, or none. Either add the runner’s public key to .sops.yaml and updatekeys, or feed the runner a shared decryption key through the CI secret store. Do not paste the private key into a repo variable that ends up in logs.
A merge conflict inside an encrypted file. These are ugly because the ciphertext is opaque and the mac will reject a hand-merged file. The reliable fix is to decrypt both sides, resolve in plaintext, and re-encrypt, rather than trying to merge ciphertext line by line. Keeping one secret per logical file reduces how often this happens.
Committed a plaintext secret by accident. The encrypted file is safe; a plaintext one that slipped past is not, and git history is forever. Treat the secret as leaked: rotate the real credential, then scrub history with git filter-repo and force-push. A pre-commit hook that refuses to commit any file matching your secrets path unless it contains ENC[ stops this happening twice.
Is it worth it?
For anyone managing infrastructure as code, SOPS and age is close to essential, and I would not start a new homelab-as-code project without it. It resolves the central tension of GitOps — that you want everything in git except the one category of thing you can’t put in git — by making secrets safe to commit. Config and the secrets it needs finally live in the same place, versioned together, reviewed together, and impossible to fall out of sync.
The learning curve is gentle, especially with age instead of a KMS. An afternoon gets you a working setup, and the .sops.yaml-plus-sops edit workflow becomes muscle memory fast. The two disciplines that matter are guarding the private key like the crown jewels — a hardware token or an offline backup, never a plaintext copy in a synced folder — and remembering that rotation means changing the real credential, not just re-encrypting the file. This pairs naturally with keeping your identity keys in hardware; once you have a YubiKey holding your SSH and signing keys, the same instinct to keep secrets out of reach of a compromised laptop extends cleanly to your config repos. Generate a key this weekend, encrypt one password, and never write a plaintext .env again.




