SOPS + age: Secrets in Git Without Pretending Git Is a Vault, Hands-On
If you have been following the secrets conversation on this blog, you have already seen two halves of it. The Docker secrets post settled what happens on one server: Docker secrets are a delivery mechanism, not a vault, and the git-native answer is SOPS plus age. The backup post then took the recovery half and made the case that recovery keys are part of the backup, not an afterthought. What neither post did was walk through SOPS itself end to end: what it actually adds on top of plain age, how you set up the keys, how .sops.yaml routes files, how it fits into GitOps, and what to do the day a key needs to rotate or the laptop dies.
This post is the sysadmin version of that walkthrough, with real commands and a real .sops.yaml. The short answer up front: git is version control, not a vault. SOPS is the thin, boring layer that makes the repo safe to share anyway.
The short version
- Git is version control, not a vault. The moment plaintext secrets land in a commit, they are in history forever. Deleting the file later does not delete the secret.
- age encrypts. SOPS manages. SOPS encrypts the values inside a file while keeping the structure readable, and it handles the routing and the multiple keys for you.
- The private key never goes in the repo. The repo holds ciphertext, and .sops.yaml tells SOPS which public key to encrypt each file for.
- GitOps decryption happens at apply time. Flux decrypts natively with an age key from a secret; Argo CD decrypts through a plugin or a kustomize secret generator.
- Rotation is a re-encrypt, not a rewrite. You add the new recipient, re-encrypt the files, and the old key stops being able to read them once you remove it everywhere.
- Recovery is a backup of the age key, encrypted again, stored offline, and tested. The restore drill is the only thing that proves the key still works.
- SOPS (getsops)
v3.13.3 (latest release, July 2026) - SOPS license
MPL-2.0, maintained under the CNCF umbrella as getsops - age
v1.3.1 (latest); v1.3.0 added post-quantum mlkem768x25519 recipients - age license
BSD-3-Clause - Flux SOPS integration
Native in kustomize-controller via decryption provider sops
Checked 2026-08-28 against the GitHub releases pages for getsops/sops (v3.13.3 latest) and FiloSottile/age (v1.3.1 latest, with v1.3.0 post-quantum recipients), the getsops docs, and the Flux SOPS guide. Both projects ship fast; re-check versions before you rely on them.
What SOPS actually is
SOPS, short for Secrets OPerationS, started at Mozilla and is now maintained as getsops under the CNCF umbrella. It is a CLI that encrypts the values inside structured files while leaving the structure readable. A YAML file keeps its keys, an ENV file keeps its variable names, and only the values become ciphertext. That is the property that makes it work in git: the file is still diffable, reviewable, and mergeable, it just contains encrypted values.
Under the hood, SOPS generates a random data key per file, encrypts the values with that key, and then encrypts the data key for every recipient you listed. Recipients can be age public keys, PGP keys, or cloud KMS keys. age is the modern default, which is why this post pairs them. The data key is what makes multi-recipient files work: two people can each hold a different age key and both decrypt the same file, and removing one recipient only re-encrypts that file, not everything everywhere.
What SOPS is not: a daemon, a server, a web UI, or a vault. It is a CLI you run on your machine and in your pipelines. That is the whole point. The Docker secrets post ranked it as the best git-native option because it adds no moving parts, and this post is the proof.
What age actually is
age is a file encryption tool by Filippo Valsorda, licensed BSD-3-Clause, designed as the modern replacement for the encryption half of PGP. It uses X25519 for key exchange and ChaCha20-Poly1305 for the payload, and its whole design goal is small, auditable, and hard to misuse. Version 1.3.0 added post-quantum recipients built on mlkem768x25519, so the modern age is not stuck with a single algorithm if the threat model grows.
The key model is the simplest part. age-keygen writes a file with the private key, and prints the public key that you share. That is the entire mental model: private key stays with you, public key goes in .sops.yaml and in the repo. age can also use passphrases instead of keys, which is exactly what you want when you back up the key itself.
Why git is not a vault
Before the commands, the one paragraph that justifies them. Git stores every commit forever, and rewriting history after a push is painful or impossible. A secret committed once lives on in history, in forks, in CI caches, in backups, and in everyone's clone. On top of that, git does not encrypt anything at rest, and every collaborator with read access can read every file. The failure is not the commit you delete the next day, it is the secret that was visible for five minutes on a public repo and scraped within two. SOPS does not fix history that already leaked; it prevents the leak from being committed in the first place.
Hands-on: the key setup
Everything starts with one age keypair on your admin machine. This key is the root of the whole setup, so generate it once and back it up before you encrypt anything you care about.
$ age-keygen -o ~/.config/sops/age/keys.txt
# Public key: age1abc123def...
$ chmod 600 ~/.config/sops/age/keys.txt
$ export SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt"SOPS_AGE_KEY_FILE is the environment variable sops reads to find the identity. The default location is ~/.config/sops/age/keys.txt, so the export is mostly for CI machines where the key lives somewhere else. Keep the file mode 0600, keep it out of the repo, and write the public key down where you will need it for .sops.yaml.
Hands-on: .sops.yaml, the routing rules
A file named .sops.yaml at the repo root tells sops which age public key to use for which files. The rules match on path regex, and the first rule that matches wins. You can have one key for everything, or split per path so staging and production secrets can live in the same repo with different recipients.
# .sops.yaml
creation_rules:
- path_regex: ^\.env$
age: age1abc123def...
- path_regex: secrets\.ya?ml$
age:
- age1abc123def...
- age1xyz987... # second recipient, e.g. a CI or colleague keyOnce .sops.yaml exists, you no longer pass the key on the command line for new files. sops reads the rules, picks the matching age public keys, and encrypts to all of them. That is the file that makes the workflow repeatable and the review simple: a change to routing is a normal diff.
The daily workflow: encrypt, edit, decrypt
Encrypting a file for the first time is one command. The plaintext version stays out of the repo; the .sops version goes in.
# create the encrypted artifact from a local plaintext file
$ sops --encrypt .env > .env.sops
# edit in place: sops opens $EDITOR on a decrypted temp file,
# then re-encrypts when you save
$ sops .env.sops
# decrypt at deploy time, never commit the output
$ sops --decrypt .env.sops > .env
# set one value without opening the editor
$ sops set .env.sops '["DB_PASSWORD"]' 'new-value'The nice property shows up in git. A diff of .env.sops shows that a value changed, and that the MAC changed, without showing the value. Reviewers see the structure, the secret stays ciphertext, and merge conflicts are still solvable because the keys are readable.
$ git diff
--- a/.env.sops
+++ b/.env.sops
@@ -1,4 +1,4 @@
-DB_PASSWORD=ENC[AES256_GCM,data:abc123,iv:...,tag:...]
+DB_PASSWORD=ENC[AES256_GCM,data:def456,iv:...,tag:...]
sops:
age:
- recipient: age1abc123def...GitOps integration: where decryption happens
The Argo CD vs Flux post called this out: both tools keep secrets out of git, but decryption lives in different places. Flux decrypts natively at apply time. You hand it the age key once as a secret, and the kustomize-controller decrypts .sops files before applying them. No plugin, no sidecar, no manual step.
# on your admin machine
$ flux create secret sops-age --age-file=age.agekey
# Secret created: flux-system/sops-age
# the key never enters the repo; the kustomization just references itapiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: apps
namespace: flux-system
spec:
interval: 10m
path: ./apps
prune: true
sourceRef:
kind: GitRepository
name: flux-system
decryption:
provider: sops
secretRef:
name: sops-ageArgo CD has no built-in SOPS, so you use one of the two standard paths: the kustomize secret generator that reads the encrypted file with an age key mounted into the repo-server, or a cmp sidecar plugin that runs sops during manifest generation. Either way the age key ends up inside the cluster, which is the part to protect.
Key rotation
Rotation means two things in SOPS, and it is worth keeping them separate. Rotating the data key re-encrypts a file with a fresh random key, which you do periodically or when you suspect a file has been exposed. Rotating recipients changes who can decrypt: you add the new age public key, re-encrypt, and remove the old one.
# add the new recipient to .sops.yaml first, then re-encrypt
$ sops updatekeys secrets.enc.yaml
# re-encrypts the file with the current key list from .sops.yaml
# rotate the data key, keep the recipients
$ sops rotate secrets.enc.yaml
# repeat for every encrypted file, then remove the old
# public key from .sops.yaml and commitThe honest part: rotation only works if you stop using the old key and remove it from every file. A leaked key that still decrypts old ciphertext is not rotated, it is ignored. Script it: a small loop over the encrypted files runs updatekeys after every .sops.yaml change, and CI fails if any file references a recipient that is no longer in the rules.
Recovery strategy: the key is the backup
Here is the failure that hurts the most: the repo is fine, the ciphertext is fine, and the age key is gone with the laptop. Every encrypted file is unreachable forever. The recovery strategy is a backup of the key that is encrypted again, stored offline, and tested, exactly the discipline the backup post applies to restic and Borg keys.
# encrypt the key with an age passphrase for transport
$ age -p -o ~/.backup/keys.txt.age < ~/.config/sops/age/keys.txt
# store that file on a USB stick in a drawer, in your password
# manager, and optionally print the paper backup
# restore drill on a fresh machine
$ age -d -i ~/.backup/keys.txt.age > ~/.config/sops/age/keys.txt
$ chmod 600 ~/.config/sops/age/keys.txt
$ export SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt"
$ sops --decrypt secrets.enc.yaml > /tmp/restore-test.yamlThe limits that bite
- SOPS is not a vault. No access control, no audit log, no revocation, no dynamic secrets. Anyone holding an authorized age key can decrypt everything encrypted to it, and you cannot tell who did.
- The age key is a single point of failure. One key protects the whole repo. Losing it loses everything; leaking it exposes everything. The mitigation is the backup and rotation sections above, not a feature of SOPS.
- Value-level encryption keeps structure visible. Reviewers can see which secrets exist and how many, even if they cannot read the values. If that metadata itself is sensitive, SOPS has a binary mode that encrypts the whole file.
- No built-in rotation or expiry. You build the rotation into your workflow and CI. SOPS will happily keep using a key you thought you retired.
- History leaks stay leaked. SOPS prevents future commits from containing plaintext. It does not fix a secret that was committed before you adopted it. That is a rotation plus history work.
- Root on the deploy host can read the key. SOPS protects at rest and in transit. A compromised server that holds the key can decrypt everything, which is exactly why the Docker secrets post said encryption at rest protects against a stolen disk, not against root on a live host.
- CLI only. No daemon, no web UI, no central server. That is a feature for a sysadmin and a limit for teams that expect a dashboard.
| Feature | SOPS + age | Vault / OpenBao |
|---|---|---|
| Where secrets live | Encrypted files in git | Centralized secret store |
| Server to run | None, CLI plus your repo | Yes, a real service to patch and back up |
| Access control | None, any key holder decrypts | Policies, tokens, leases |
| Audit trail | Git history of encrypted files | Full audit log of reads and writes |
| Dynamic secrets | No, static values | Yes, short-lived generated credentials |
| Revocation | Rotate recipients and re-encrypt | Revoke token or lease immediately |
| GitOps | Native on Flux, plugin on Argo CD | External secrets operator or sidecar |
| Best for | Small teams, git-native repos, no extra infra | Compliance, dynamic credentials, many consumers |
Where secrets live
- SOPS + age
- Encrypted files in git
- Vault / OpenBao
- Centralized secret store
Server to run
- SOPS + age
- None, CLI plus your repo
- Vault / OpenBao
- Yes, a real service to patch and back up
Access control
- SOPS + age
- None, any key holder decrypts
- Vault / OpenBao
- Policies, tokens, leases
Audit trail
- SOPS + age
- Git history of encrypted files
- Vault / OpenBao
- Full audit log of reads and writes
Dynamic secrets
- SOPS + age
- No, static values
- Vault / OpenBao
- Yes, short-lived generated credentials
Revocation
- SOPS + age
- Rotate recipients and re-encrypt
- Vault / OpenBao
- Revoke token or lease immediately
GitOps
- SOPS + age
- Native on Flux, plugin on Argo CD
- Vault / OpenBao
- External secrets operator or sidecar
Best for
- SOPS + age
- Small teams, git-native repos, no extra infra
- Vault / OpenBao
- Compliance, dynamic credentials, many consumers
Which should you pick?
- Choose SOPS + age when secrets already live in git-shaped config, you want GitOps to decrypt at apply time, you do not want to run another server, and you are comfortable with the single-key discipline.
- Choose a real vault when you need dynamic credentials, leases, audit trails, fine-grained policies, or you outgrow static files. Vault and OpenBao exist for that, and the Docker secrets post has the full comparison of when the weight pays for itself.
- Use plain age when you just need to encrypt a tarball or a single file and there is no structure to preserve. SOPS is the layer for files that need to stay reviewable in a repo.
- Consider neither when it is one server and a couple of env files. A 0600 .env plus systemd credentials might be all you need, and the Docker secrets post ranks exactly when that is the honest answer.
My own rule after running this for a while: SOPS + age is the default for anything that lives in a repo, on a homelab or a small team, because it removes plaintext from git without adding a platform. The cost is the single key, and the answer to that cost is the backup and rotation discipline from the sections above, not a bigger tool.
Official sources
- getsops/sops: https://github.com/getsops/sops
- SOPS documentation: https://getsops.io/docs/
- age: https://age-encryption.org/
- age releases: https://github.com/FiloSottile/age/releases
- Flux SOPS guide: https://fluxcd.io/flux/guides/mozilla-sops/
- Argo CD secret management: https://argo-cd.readthedocs.io/en/stable/operator-manual/secret-management/
- Our Docker secrets post: https://systhoughts.com/posts/docker-secrets-arent-really-secrets
- Our backup and recovery keys post: https://systhoughts.com/posts/3-2-1-backup-rule-not-enough-self-hosted-infrastructure
- Our Argo CD vs Flux post: https://systhoughts.com/posts/argocd-vs-flux-gitops-comparison
Are you keeping secrets with SOPS + age, plain age, or a full vault? Where does your age key live the day the laptop dies, and have you actually restored from it? Drop it in the comments.
Until next time, keep your systems thoughtful.

No comments yet