SOPS + age: Secrets in Git Without Pretending Git Is a Vault, Hands-On

SOPS plus age is the git-native way to keep secrets out of plaintext commits: age key setup, .sops.yaml creation rules, the daily encrypt and decrypt workflow, GitOps integration with Flux and Argo CD, key rotation, a recovery strategy that survives a lost laptop, and the honest limits of the pattern.

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.
Verified
  • SOPS (getsops)v3.13.3 (latest release, July 2026)
  • SOPS licenseMPL-2.0, maintained under the CNCF umbrella as getsops
  • agev1.3.1 (latest); v1.3.0 added post-quantum mlkem768x25519 recipients
  • age licenseBSD-3-Clause
  • Flux SOPS integrationNative 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.

generate the age keypair
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 key

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

encrypt, edit, decrypt
# 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.

what a git diff of an encrypted file looks like
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.

Flux: one command to give the cluster the key
# 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 it
apiVersion: 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-age

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

rotate a recipient and rotate a data key
# 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 commit

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

back up and restore the age key
# 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.yaml

The 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.
SOPS + age vs a dedicated vault
FeatureSOPS + ageVault / OpenBao
Where secrets liveEncrypted files in gitCentralized secret store
Server to runNone, CLI plus your repoYes, a real service to patch and back up
Access controlNone, any key holder decryptsPolicies, tokens, leases
Audit trailGit history of encrypted filesFull audit log of reads and writes
Dynamic secretsNo, static valuesYes, short-lived generated credentials
RevocationRotate recipients and re-encryptRevoke token or lease immediately
GitOpsNative on Flux, plugin on Argo CDExternal secrets operator or sidecar
Best forSmall teams, git-native repos, no extra infraCompliance, 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