Encrypt secrets in Git with SOPS and age
Keep encrypted secrets safely in your repo, decrypt them in CI, and never commit a plaintext credential again.
A GitOps repository wants every input in Git, and a database password is an input. The two bad answers are committing it in plaintext and keeping it somewhere the reconciler cannot see. SOPS is the third answer for the static, low-volume secrets that belong with the manifests: it encrypts the values of a YAML, JSON, ENV or INI file and leaves the keys readable, so git diff still shows that db_password changed and code review still works. Paired with age, a small X25519-based encryption tool, there is no KMS bill and no key server, only a public key in the repo and a private key wherever decryption happens.
The mechanism is worth understanding before the commands, because it explains the one operational rule people get wrong: SOPS generates a random data key per file, encrypts each matched value with it, and then stores that data key once per recipient, wrapped with the recipient's public key, in a plaintext sops: block at the bottom of the same file.
Any single private key unwraps the data key and therefore the whole file. Removing a recipient from .sops.yaml edits the list, but the data key an old commit was encrypted with does not change until it is rotated, so offboarding is a rotate, not a delete.
Keys: one identity per decrypting party
Every person and every CI job that decrypts gets its own age identity. age-keygen writes a file containing both halves; the line starting # public key: age1… goes into .sops.yaml, the AGE-SECRET-KEY-… line goes into a password manager or a CI secret store and nowhere near the repository. SOPS looks for identities in SOPS_AGE_KEY (the key text), SOPS_AGE_KEY_FILE (a path), SOPS_AGE_KEY_CMD (a command that prints keys), and otherwise in keys.txt under the user config directory: ~/.config/sops/age/keys.txt on Linux, ~/Library/Application Support/sops/age/keys.txt on macOS. SSH ed25519 and RSA public keys are also accepted as recipients, which is a reasonable shortcut for a team that already manages SSH keys.
mkdir -p ~/.config/sops/age && age-keygen -o ~/.config/sops/age/keys.txtPublic key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8pchmod 600 ~/.config/sops/age/keys.txtgrep "public key" ~/.config/sops/age/keys.txt# public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8ponly this line ever goes into the repository.sops.yaml: which paths encrypt to which recipients
Creation rules map a path pattern to a recipient list, so platform engineers can decrypt secrets/infra/ while an application team decrypts only secrets/apps/shop/, with no wrapper scripts. encrypted_regex limits encryption to the keys that hold secret material; leaving apiVersion, kind and metadata readable is what keeps a Kubernetes Secret manifest reviewable and lets tooling that inspects manifests keep working.
creation_rules:- path_regex: secrets/infra/.*\.yaml$encrypted_regex: '^(data|stringData)$'age: >-age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p,age1<ci-job-public-key>- path_regex: secrets/apps/shop/.*\.yaml$encrypted_regex: '^(data|stringData)$'age: age1<shop-team-public-key>
Encrypt in place, then read the diff
sops encrypt -i rewrites the file in place using the rule that matches its path; sops edit opens the decrypted content in $EDITOR and re-encrypts on save. After encryption every matched value is an ENC[AES256_GCM,data:…,iv:…,tag:…,type:str] token and the keys are untouched, which is exactly what a reviewer needs: they can see that db_password was rotated without seeing either password.
sops encrypt -i secrets/infra/database.yaml; echo exit=$?exit=0git diff -- secrets/infra/database.yaml | grep -E "^[-+] +(db_password|db_user|host|name)"- name: database- host: db.infra.svc.cluster.local+ name: database+ host: db.infra.svc.cluster.local- db_user: app- db_password: not-a-real-password+ db_user: ENC[AES256_GCM,data:BUSQ,iv:DU9A7yWL46J4DeESNIM7SPqBSZ5qGnX8OT2kjopl+Mo=,tag:oBYko9vortmASu8hC5mVxA==,type:str]+ db_password: ENC[AES256_GCM,data:iWKBAn5NhEkW2l71xfRKOBId0A==,iv:4Kkw7+McUtwFEjddDbLgVZE3IgStp+JejP/9k/lt9nA=,tag:0Lh5qWkSZnubITMeDT4VWg==,type:str]keys readable, the stringData values ciphertext, the host annotation untouched because it did not match encrypted_regex. The other thing in this first diff: sops rewrote every line at four-space indentation, so the untouched keys show up as changed too. That happens once; later edits diff cleanlygrep -E "^sops:|^ *age:|^ *- enc: \||^ *recipient: age1|^ *encrypted_regex:|^ *mac: ENC|^ *version:" secrets/infra/database.yaml | cut -c1-60sops: age: - enc: | recipient: age1mglxvmddf3a4rmwmwlchzzzwq9zkrymzf78 encrypted_regex: ^(data|stringData)$ mac: ENC[AES256_GCM,data:K1QaG2BJvNRBNIpvi1G4enqGiO9XeKf version: 3.13.3the metadata block: one wrapped data key per recipient, the rule that was applied, and a MAC over the values so a tampered file fails to decryptSOPS_AGE_KEY_FILE=keys/alice.txt sops decrypt secrets/infra/database.yaml | grep -E "db_password|db_user" db_user: app db_password: not-a-real-passwordSOPS_AGE_KEY_FILE=keys/bob.txt sops decrypt secrets/infra/database.yaml; echo exit=$? # bob was never a recipientFailed to get the data key required to decrypt the SOPS file.Group 0: FAILED age1mglxvm…: FAILED - | failed to create reader for decrypting sops data key with | age: identity did not match any of the recipients: incorrect | identity for recipient block.exit=128exit 128 is what a pipeline sees for a missing or wrong identity; with no identity at all the message lists every place it looked, ending with ~/.config/sops/age/keys.txt. Decrypt locally only to read or edit a value; the file in Git stays encryptedThe order matters more than any flag: encrypt before the first commit. Deleting a plaintext secret in the next commit does not remove it from clones, forks, CI caches or backups, and at that point rotation of the credential is the only honest fix. A gitleaks pre-commit hook turns that rule into something a fat-fingered git add . cannot break.
Adding and removing recipients
Adding a teammate is two commands: put their public key in .sops.yaml, then run sops updatekeys on each affected file so the existing data key is wrapped for the new recipient as well; the run confirmed that the ciphertext of every value is byte-identical before and after, which is what "same data key, one more wrap" means. Removing someone is where the envelope model bites. sops updatekeys after deleting their key rewrites the wrapped-key list and does lock them out of the file as it now stands, but the data key is still the same one, and it is inside every commit they could previously decrypt, which their private key still unwraps: in the run, the previous commit opened with the removed key after updatekeys and after rotate alike. sops rotate -i generates a new data key and re-encrypts the values, and only that changes what the current version is encrypted with; sops rotate -i --rm-age <key> does the removal and the rotation in one step. History stays readable to them regardless; if that matters, the secrets themselves are rotated too.
sops updatekeys -y secrets/infra/database.yaml # after adding the ci key to .sops.yamlThe following changes will be made to the file's groups:Group 1 age1mglxvmddf3a4rmwmwlchzzzwq9zkrymzf78keq7tfzg5c2v58y0qtxm5k9+++ age19f2uldqt6m4sp8ta5s284p4c5e9lw2l5refvvh9m00hra6wap37q4m3kknFile /repo/secrets/infra/database.yaml synced with new keysgit commit -qam "encrypted for alice and ci"sops updatekeys -y secrets/infra/database.yaml # alice removed from .sops.yaml, run with the ci key--- age1mglxvmddf3a4rmwmwlchzzzwq9zkrymzf78keq7tfzg5c2v58y0qtxm5k9SOPS_AGE_KEY_FILE=keys/alice.txt sops decrypt secrets/infra/database.yaml; echo exit=$?exit=128git show HEAD:secrets/infra/database.yaml > old.yaml && SOPS_AGE_KEY_FILE=keys/alice.txt sops decrypt old.yaml | grep -c db_password1the working-tree file refuses her; the committed version, encrypted with the same data key, does notsops rotate -i secrets/infra/database.yaml # run with the ci key: new data key, values re-encryptedSOPS_AGE_KEY_FILE=keys/alice.txt sops decrypt old.yaml | grep -c db_password1rotate changes what the current version is encrypted with (every ENC[...] token changed) and nothing about history. sops rotate -i --rm-age <alice-key> did both steps at once on a copy: one recipient left, alice refused, ci reads itDecrypting where the deploy happens
In a pipeline the private key arrives through the CI secret store as SOPS_AGE_KEY, and the decrypted output is piped straight into the tool that needs it, so no plaintext file is written to the runner. For Flux and Argo CD the better shape is to give the controller the identity and let it decrypt at reconcile time, so developer laptops never need the production key at all; Flux's kustomize-controller reads it from a Secret referenced by the Kustomization's decryption block. Either way the plaintext exists only in memory and in the target system, never in the repository.
deploy:variables:SOPS_AGE_KEY: $AGE_PRIVATE_KEY # masked CI variable, never in the reposcript:# decrypt straight into kubectl: no plaintext file on the runner's disk- sops decrypt secrets/infra/database.yaml | kubectl apply -f -
When a change locks people out: recovery in order of preference
| Situation | Do | Do not |
|---|---|---|
| a file was rotated or updatekeys removed a recipient by mistake, and the right person cannot decrypt | any remaining recipient runs sops updatekeys -y <file> after restoring the key in .sops.yaml; verify with SOPS_AGE_KEY_FILE=<their key> sops decrypt <file> >/dev/null; echo $? (0) | copy the plaintext around to fix it: the whole point of the file is that nobody has to |
| no remaining recipient can decrypt (the last private key is gone) | the encrypted values are unrecoverable; restore the secret from where it was issued, re-create the file, and add a break-glass recipient (an offline key in a safe) before the next incident | git revert in the hope an older commit helps: it is encrypted with the same lost keys |
| a private key was exposed | sops rotate -i --rm-age <key> <file> on every file that lists it, in one commit, then rotate the secrets themselves: the exposed key still opens every earlier commit | remove the key from .sops.yaml and stop: history stays readable to it |
SOPS answers the storage question and stops there. It cannot tell you who decrypted a file last week, it does not expire anything, and it cannot hand a job a database credential that dies in ten minutes. Those are the reasons a team eventually moves the source of truth to Vault and syncs into the cluster with External Secrets, keeping SOPS for the bootstrap secrets that have to exist before Vault does. Until then, the two habits that matter are the ones above: encrypt before the first commit, and rotate when someone leaves.
Go deeper in a courseSOPSEncrypt secrets in Git with SOPS, age and cloud KMS master keys.View course