SOPS and sealed-secrets

Encrypted values in git, for GitOps flows.

Beginner30 min · lesson 9 of 13

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

shell
# 1. Install (macOS: brew install sops age)
curl -LO https://github.com/getsops/sops/releases/download/v3.13.2/sops-v3.13.2.linux.amd64
sudo install -m 755 sops-v3.13.2.linux.amd64 /usr/local/bin/sops
# 2. Generate an age keypair — the private key never leaves your machine
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
# -> Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
.sops.yaml
# Committed at the repo root: which keys may encrypt which files
creation_rules:
- path_regex: .*\.secrets\.yaml$
age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
shell
# 3. Encrypt in place — SOPS picks recipients from .sops.yaml
sops encrypt --in-place db.secrets.yaml
cat 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 $EDITOR
sops decrypt db.secrets.yaml
sops 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.

shell
# Install the controller once per cluster
helm repo add sealed-secrets https://bitnami.github.io/sealed-secrets
helm 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 API
kubectl 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.yaml
grep -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 created
kubectl get secret db-creds -n prod
# NAME TYPE DATA AGE
# db-creds Opaque 1 4s
Which encrypted-secrets approach fits?
Store secrets in git
commit ciphertext, never plaintext
Multi-platform, and you need a decrypt audit trail
SOPS (+ KMS)
Feeds Terraform, CI, docker-compose and Kubernetes; cloud IAM answers who decrypted. The cost: every human and every pipeline needs its own private key
Kubernetes-only, and you want zero key distribution
sealed-secrets
Devs touch only the public cert; the controller holds the key. Ciphertext is per-cluster, no decrypt audit, and the sealing-key backup is do-or-die
You need rotation, central revocation, or who-used-it
Central secret manager
Vault or a cloud store (next lesson). The price is running more infrastructure
Both file tools encrypt static files only: no dynamic credentials, no automatic rotation, no central revocation. Fine at ~5 services; a bottleneck at ~200.

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.

Back up the sealing key, or lose the lot
The controller's private key lives in exactly one place: inside the cluster. Delete the cluster, or wipe 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.

terminal
mkdir -p /tmp/sf-sops && cd /tmp/sf-sops
age-keygen -o keys.txt 2>pub.txt
PUB=$(grep -o "age1[a-z0-9]*" pub.txt | head -1)
printf "db_password: hunter2\n" > db.secrets.yaml
cat > .sops.yaml <<EOF
creation_rules:
- path_regex: .*\.secrets\.yaml$
age: $PUB
EOF
SOPS_AGE_KEY_FILE=keys.txt sops encrypt --in-place db.secrets.yaml
grep -E "db_password|sops:" db.secrets.yaml | head -5
output
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.

Quick check
01A developer who held a SOPS age private key leaves your team. You delete their age recipient from .sops.yaml and run sops updatekeys on every encrypted file. Why is the secret still not safe?
Incorrect — Tempting, and half right. It does shut off future access, but that person already read the plaintext, so the credential itself is compromised.
Correct — Offboarding is three steps: remove the recipient, run sops updatekeys, and rotate the underlying secret, because they have already seen it.
Incorrect — No. The lesson adds and removes age recipients with updatekeys. It is not a KMS-only command.
Incorrect — No. The 30-day key renewal belongs to the sealed-secrets controller, not to SOPS, and even there it only affects new seals.
02Why does the sealed-secrets sealing-key backup count as a crown-jewel file that must never sit in git?
Incorrect — No. The public cert only encrypts. Decryption needs the private sealing key.
Correct — Both your disaster recovery and your confidentiality hang on that one private key.
Incorrect — No. SOPS and sealed-secrets are separate tools with entirely separate key material.
Incorrect — No. The sealing key belongs in the cluster and in an offline backup. Git is only where ciphertext goes.
03Promoting a release, you copy 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?
Incorrect — No. Scope only decides whether the name and namespace are baked into the ciphertext; it says nothing about which cluster's key can open it. You would give away the strict-scoped replay protection the lesson tells you to keep and still have no Secret.
Incorrect — No. That hands the crown-jewel private key around and dissolves the boundary that stops a staging value being replayed into prod. Different seals per cluster is the design, not the fault.
Correct — The prod controller holds a different private key, so it cannot decrypt a seal made for staging. Ciphertext is per-cluster: re-seal for every cluster, and make the pipeline do it so nobody is tempted to loosen the scope instead.
Incorrect — No. 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.

Related