SOPS and sealed-secrets
Encrypted values in git, for GitOps flows.
A padlock is a strange object. Anyone can snap one shut. Only the person holding the key can open it again. Public-key encryption, the maths behind both tools in this lesson, copies that shape into software: you hand out thousands of open padlocks, other people lock boxes with them, and not one of those people can reopen a single box. Only you can. SOPS and sealed-secrets apply that padlock to files. Anyone on the team can encrypt a secret. Only a chosen keyholder can decrypt it. The locked box is then safe to leave lying around anywhere, including a git repository.
That last sentence is the whole point. You already know two uncomfortable facts. Git history is effectively permanent, and a Kubernetes Secret is base64-encoded plaintext, which is encoding, not encryption: anyone who can read the object can decode it in one command. Yet GitOps, the practice of keeping every manifest in git and having the cluster sync itself from that repo, insists that secrets live in the repo somehow. SOPS and sealed-secrets settle that argument the same way. You commit ciphertext, the scrambled and unreadable output of encryption, instead of the value. A leaked repo then leaks a pile of locked boxes and nothing else.
SOPS: scrambled values, readable file
SOPS (short for Secrets OPerationS, born at Mozilla, now a CNCF project, CNCF being the Cloud Native Computing Foundation that also houses Kubernetes) is a command-line tool that encrypts YAML, JSON, .env, INI and binary files. Its clever bit is what it leaves alone. SOPS encrypts only the *values* and leaves the field names in plain sight, so password: hunter2 turns into password: ENC[AES256_GCM,...]. Code review still shows *which* setting somebody changed. git diff still means something. The file is still valid YAML, so every tool that parses YAML can still parse it.
Underneath, SOPS uses envelope encryption. Lock the papers in a strongbox, then lock the strongbox key inside a small safe, and now you only have to guard the safe. For each file, SOPS invents a fresh random data key and encrypts every value with it using AES-256-GCM, a fast cipher that also detects tampering. Then it encrypts that data key with one or more master keys: an age keypair, AWS KMS, GCP KMS, Azure Key Vault or HashiCorp Vault. (KMS is Key Management Service, the cloud provider's own key custodian.) age is the padlock from a moment ago in software form, a deliberately tiny encryption tool whose public keys are short strings starting with age1. The wrapped data key, the recipient list, and a MAC (message authentication code, a cryptographic seal that shouts if a single byte has been altered) all sit in a sops: block at the bottom of the file. The file carries everything needed to decrypt itself, provided you hold the matching private key.
Four commands to a working SOPS setup
# 1. Install (macOS: brew install sops age)curl -LO https://github.com/getsops/sops/releases/download/v3.13.2/sops-v3.13.2.linux.amd64sudo install -m 755 sops-v3.13.2.linux.amd64 /usr/local/bin/sops# 2. Generate an age keypair — the private key never leaves your machinemkdir -p ~/.config/sops/ageage-keygen -o ~/.config/sops/age/keys.txt# -> Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
# Committed at the repo root: which keys may encrypt which filescreation_rules:- path_regex: .*\.secrets\.yaml$age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
# 3. Encrypt in place — SOPS picks recipients from .sops.yamlsops encrypt --in-place db.secrets.yamlcat db.secrets.yaml# db_user: ENC[AES256_GCM,data:kF2mZw1zXg==,iv:QyN...,tag:5rD...,type:str]# db_password: ENC[AES256_GCM,data:9tPqB2c...,iv:8Lm...,tag:JmW...,type:str]# sops:# age:# - recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p# enc: |# -----BEGIN AGE ENCRYPTED FILE-----# ...# lastmodified: "2026-07-13T09:14:02Z"# mac: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]# version: 3.13.2# 4. Work with it: decrypt to stdout, or round-trip through your $EDITORsops decrypt db.secrets.yamlsops edit db.secrets.yaml
.sops.yaml is the steering wheel. It pins *which* public keys may encrypt *which* paths, so nobody pastes a recipient string by hand and nobody fat-fingers it. Adding a teammate takes two steps: append their age1... key, then run sops updatekeys db.secrets.yaml. SOPS re-wraps the data key for the new recipient and never touches the values.
In CI (continuous integration, the pipeline that builds and ships your code), set SOPS_AGE_KEY_FILE, or SOPS_AGE_KEY with the key contents inline, and decrypt at deploy time. Better still, never write plaintext to disk at all. sops exec-env db.secrets.yaml 'terraform apply' decrypts into the environment of one process, and that environment dies with the command.
sealed-secrets: the cluster holds the key
sealed-secrets (from Bitnami Labs) goes after the same problem, but only inside Kubernetes, and it picks a different keyholder: the cluster itself. A controller, a small program running inside the cluster that watches for work, generates the keypair and never hands out the private half. The kubeseal command-line tool fetches the public certificate and encrypts an ordinary Secret manifest into a SealedSecret, a custom resource (a brand new object type registered with the Kubernetes API, so kubectl treats it like any built-in kind). You apply the SealedSecret. The controller decrypts it inside the cluster and creates the native Secret from it. No human ever holds a decryption key.
# Install the controller once per clusterhelm repo add sealed-secrets https://bitnami.github.io/sealed-secretshelm install sealed-secrets sealed-secrets/sealed-secrets -n kube-system# Fetch the cluster's public cert (safe to share, safe to commit)kubeseal --fetch-cert \--controller-name sealed-secrets \--controller-namespace kube-system > pub-sealed-secrets.pem# Seal: --dry-run=client means the plaintext Secret is never sent to the APIkubectl create secret generic db-creds -n prod \--from-literal=DB_PASSWORD='s3cr3t-hunter2' \--dry-run=client -o yaml \| kubeseal --cert pub-sealed-secrets.pem --format yaml > db-creds.sealed.yamlgrep -A2 encryptedData db-creds.sealed.yaml# encryptedData:# DB_PASSWORD: AgBy8hCK+3lVzL0qTmXhP9... (hundreds of chars of ciphertext)kubectl apply -f db-creds.sealed.yaml# sealedsecret.bitnami.com/db-creds createdkubectl get secret db-creds -n prod# NAME TYPE DATA AGE# db-creds Opaque 1 4s
A SealedSecret is strict-scoped by default. Its name and namespace are baked into the encryption, so the ciphertext for db-creds in prod cannot be renamed, dropped into another namespace and replayed by somebody with write access to the repo. Keep that default. --scope namespace-wide and --scope cluster-wide sell the protection off for a little convenience, and that trade is rarely worth making.
kube-system, without a backup, and every SealedSecret sitting in git becomes permanent gibberish. There is no recovery path and no support ticket that fixes it. Export the key today: kubectl get secret -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml > sealing-keys.yaml, then store that file in a vault, never in git. One detail people misread: the controller mints a new sealing key every 30 days, but that only affects *new* seals. It does not re-encrypt old ciphertext, and it does not rotate the secrets themselves.How to choose between them
SOPS does not care about your file format or your platform. The same encrypted file feeds Terraform, docker-compose, Ansible, your CI pipeline and Kubernetes (Flux decrypts SOPS on its own, Argo CD needs a plugin). Point it at a cloud KMS master key and you inherit that cloud's IAM (identity and access management, the rules about who may do what) along with its audit logs, so you can answer the question every incident review asks: who decrypted this, and when? The bill arrives as key distribution. Every human and every pipeline that decrypts needs a private key of its own. Offboarding somebody means removing their recipient, running sops updatekeys, and rotating the underlying secrets, because that person already read the plaintext.
sealed-secrets flips the deal. There is nothing to distribute, because developers only ever handle the public certificate. The costs land elsewhere. It works on Kubernetes and nowhere else. Ciphertext is per-cluster, so a secret sealed for staging has to be re-sealed for prod, and again for every new cluster you build. There is no record of who decrypted what. And your whole disaster recovery story rests on that one key backup.
Both tools share an honest limit: they encrypt *static files*. No dynamic credentials that expire on their own, no automatic rotation, no per-application access policy, no single place to revoke. At five services and one team that trade is a bargain, because there is almost no infrastructure to run. At two hundred services it bites: re-encrypting on every departure, re-sealing on every new cluster, and "rotate the leaked credential" turning into a commit against every affected repo. Guardrails either way. Pin recipients in .sops.yaml. Put keys.txt in your global gitignore. Add a pre-commit hook that rejects any *.secrets.yaml missing a sops: block. Treat sealing-key backups the way you treat the office safe.
That limit is the door into the final lesson. Encrypted files answer one question well: how do I keep a secret in git without handing it to strangers? They cannot answer the other two. Who used this credential, and when? How do I kill it everywhere in one move? Central secret managers, Vault and the cloud-native stores, answer exactly those, and charge you in infrastructure to run. Next up: how to tell when files have stopped being enough.
Here is a failure you will meet in the wild. Somebody commits a plaintext password "only for the demo", the change gets merged, and three weeks later the demo password is the production password. Encrypted-file workflows save you only if CI refuses unencrypted secrets paths. Add a pre-commit check that looks for a sops: stanza (or a SealedSecret kind) on those file globs, and make the pipeline fail hard rather than print a warning nobody reads.
Multi-cluster sealed-secrets means multi-ciphertext. Staging and prod get different seals, full stop. That is annoying, and it is correct, because it stops a sealed value from being replayed into a trust boundary it was never meant for. Automate the reseal step in your promotion pipeline instead of loosening scope to cluster-wide to make the annoyance go away.
When a teammate leaves, SOPS offboarding has three steps, not two: remove the recipient, run updatekeys, then rotate every value that person could have decrypted. Skip the third and "we removed their key" is theatre. Same story if a CI age key leaks through a runner log. Assume every data key it protected is burned, and rotate.
Pick one path and write it into the repo README: SOPS when your GitOps spans several tools, sealed-secrets when you want the cluster to be the only thing on earth that can decrypt. Mixed setups quietly teach newcomers to commit plaintext, "because the other app does it that way". Add CI that fails on any secrets path missing its ciphertext markers, and rehearse a sealing-key restore once a year, so disaster recovery is something you have done rather than something you have written down.
Read the diffs on encrypted files properly. SOPS keeps field names visible, so a renamed field or a new recipient line is a real change even when every value still reads ENC[...]. An attacker who can open a pull request will try to slip in a recipient key they control. Protect .sops.yaml with CODEOWNERS, the file that forces named reviewers onto specific paths, exactly the way you protect your CI workflows.
Try this
Encrypt a tiny secrets file with age and SOPS on your own machine. Two things to look for in the result: field names you can still read, and values replaced by ENC[...]. No cluster needed for this half.
mkdir -p /tmp/sf-sops && cd /tmp/sf-sopsage-keygen -o keys.txt 2>pub.txtPUB=$(grep -o "age1[a-z0-9]*" pub.txt | head -1)printf "db_password: hunter2\n" > db.secrets.yamlcat > .sops.yaml <<EOFcreation_rules:- path_regex: .*\.secrets\.yaml$age: $PUBEOFSOPS_AGE_KEY_FILE=keys.txt sops encrypt --in-place db.secrets.yamlgrep -E "db_password|sops:" db.secrets.yaml | head -5
db_password: ENC[AES256_GCM,data:...]sops:age:- recipient: age1...# keys readable, values ciphertext — safe to commit the yaml, never keys.txt
Takeaway
The idea to carry out of here: git can hold your secrets as long as what it holds is ciphertext. SOPS travels across tools, sealed-secrets keeps the private key locked inside one cluster. Neither one rotates a secret for you, and neither one tells you who read it.
Next steps you can take this week. Pin your recipients in .sops.yaml, or stay on strict-scoped SealedSecrets. Get the sealing-key backup off the cluster and into offline storage. Work out the team size at which file encryption stops paying for itself.
.sops.yaml and run sops updatekeys on every encrypted file. Why is the secret still not safe?sops updatekeys, and rotate the underlying secret, because they have already seen it.age recipients with updatekeys. It is not a KMS-only command.db-creds.sealed.yaml out of the staging repo into the prod repo. kubectl apply -f db-creds.sealed.yaml reports sealedsecret.bitnami.com/db-creds created, but kubectl get secret db-creds -n prod never shows the Secret appearing. What do you do next?encryptedData is ciphertext, not base64-encoded plaintext, so decoding it gives you gibberish. And committing a plain Secret is exactly the habit these tools exist to break.