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.

Mar 3, 2026·Updated ·8 min readIntermediate·By SecOpsLog · command-tested

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.

One data key, wrapped once per recipient

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.

SOPS envelope with age: one random data key encrypts every matched value in the file, and the sops metadata block stores that data key wrapped once per recipient, so any single recipient private key can decrypt the file secrets/prod/database.yamldb_password:ENC[AES256_GCM,data:…,…]api_token:ENC[AES256_GCM,data:…,…]host:db.internal (not matched)Data key (per file)random 256-bit AES-GCM key;encrypts each matched valuesops: metadata block (plaintext, in the same file)age recipientage1ql3… (ops laptop)age recipientage1p8k… (CI job)kms recipientarn:aws:kms:… (optional)The data key is stored once per recipient, wrapped with that recipient’s public key.Any one private key unwraps it. Removing a recipient only helps once the data key isrotated (sops rotate); until then, old commits still open for the removed key.

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.

bash — representative: generate an identity (the fixture generated its keys the same way, into a temp directory)
mkdir -p ~/.config/sops/age && age-keygen -o ~/.config/sops/age/keys.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
chmod 600 ~/.config/sops/age/keys.txt
grep "public key" ~/.config/sops/age/keys.txt
# public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
only 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.

.sops.yaml
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.

bash — observed: encrypt in place, then read the diff (sops 3.13.3; a Secret manifest with stringData and a host annotation)observed
sops encrypt -i secrets/infra/database.yaml; echo exit=$?
exit=0
git 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 cleanly
grep -E "^sops:|^ *age:|^ *- enc: \||^ *recipient: age1|^ *encrypted_regex:|^ *mac: ENC|^ *version:" secrets/infra/database.yaml | cut -c1-60
sops:
age:
- enc: |
recipient: age1mglxvmddf3a4rmwmwlchzzzwq9zkrymzf78
encrypted_regex: ^(data|stringData)$
mac: ENC[AES256_GCM,data:K1QaG2BJvNRBNIpvi1G4enqGiO9XeKf
version: 3.13.3
the 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 decrypt
SOPS_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-password
SOPS_AGE_KEY_FILE=keys/bob.txt sops decrypt secrets/infra/database.yaml; echo exit=$? # bob was never a recipient
Failed 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=128
exit 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 encrypted

The 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.

bash — observed: onboard with updatekeys, offboard with updatekeys then rotate, and what history still opensobserved
sops updatekeys -y secrets/infra/database.yaml # after adding the ci key to .sops.yaml
The following changes will be made to the file's groups:
Group 1
age1mglxvmddf3a4rmwmwlchzzzwq9zkrymzf78keq7tfzg5c2v58y0qtxm5k9
+++ age19f2uldqt6m4sp8ta5s284p4c5e9lw2l5refvvh9m00hra6wap37q4m3kkn
File /repo/secrets/infra/database.yaml synced with new keys
git commit -qam "encrypted for alice and ci"
sops updatekeys -y secrets/infra/database.yaml # alice removed from .sops.yaml, run with the ci key
--- age1mglxvmddf3a4rmwmwlchzzzwq9zkrymzf78keq7tfzg5c2v58y0qtxm5k9
SOPS_AGE_KEY_FILE=keys/alice.txt sops decrypt secrets/infra/database.yaml; echo exit=$?
exit=128
git show HEAD:secrets/infra/database.yaml > old.yaml && SOPS_AGE_KEY_FILE=keys/alice.txt sops decrypt old.yaml | grep -c db_password
1
the working-tree file refuses her; the committed version, encrypted with the same data key, does not
sops rotate -i secrets/infra/database.yaml # run with the ci key: new data key, values re-encrypted
SOPS_AGE_KEY_FILE=keys/alice.txt sops decrypt old.yaml | grep -c db_password
1
rotate 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 it

Decrypting 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.

.gitlab-ci.yml
deploy:
variables:
SOPS_AGE_KEY: $AGE_PRIVATE_KEY # masked CI variable, never in the repo
script:
# 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

SituationDoDo not
a file was rotated or updatekeys removed a recipient by mistake, and the right person cannot decryptany 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 incidentgit revert in the hope an older commit helps: it is encrypted with the same lost keys
a private key was exposedsops 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 commitremove the key from .sops.yaml and stop: history stays readable to it
What was run for this article
sops 3.13.3 (the ghcr.io/getsops/sops:v3.13.3-alpine image with Alpine’s age 1.3.1 and git, Docker Engine 28.5.2, linux/arm64) against a one-file fixture: a Kubernetes Secret manifest with two stringData values and a host annotation, three age identities generated during the run and discarded at the end. The terminal blocks marked observed are copied from that run (the age1… keys are from the recorded run; every run generates new ones); twenty-five exit codes are asserted, including the refusals. The CI job, the Flux decryption and the cloud KMS comparison were not executed. In the recovery table, the updatekeys and rotate rows are what the run showed; the lost-key row follows from the envelope model and was not executed.
age vs a cloud KMS as the master key
age
No cloud dependency or bill
Works offline and in any CI
You rotate and guard the private key
No access log of who decrypted
AWS KMS / GCP KMS / Azure Key Vault
IAM decides who can decrypt
Every decrypt is in the cloud audit log
Hardware-backed key material
Needs cloud credentials wherever you decrypt

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

Related posts

Quick reference