SOPS and sealed-secrets

Encrypted values in git, for GitOps flows.

In short. SOPS and sealed-secrets for GitOps: keep encrypted values in git without committing plaintext keys.

Beginner30 min · lesson 4 of 5

Public-key encryption works like a padlock you can copy freely while keeping the only key in your pocket. Stamp out a thousand open padlocks and hand them to anyone; whoever snaps one shut on a box has locked it *even though they can never reopen it*. Only your key does that. Both tools in this lesson are that padlock applied to files: anyone can encrypt a secret, only a chosen keyholder can decrypt it, and the locked box is safe to leave anywhere — including a git repository.

That last part is the point. You already know git history is effectively permanent and that a Kubernetes Secret is base64-encoded plaintext, not protection. Yet GitOps — keeping every manifest in git and syncing the cluster from it — demands that secrets live in the repo *somehow*. SOPS and sealed-secrets resolve the contradiction the same way: commit ciphertext (the scrambled, unreadable output of encryption) instead of the value. A leaked repo then leaks nothing but locked boxes.

SOPS: encrypted values, readable files

SOPS (Secrets OPerationS — originally from Mozilla, now a CNCF project) is a command-line tool that encrypts YAML, JSON, .env, INI, and binary files. Its defining trick: it encrypts only the *values*, leaving the keys readable. password: hunter2 becomes password: ENC[AES256_GCM,...]. Code review still shows *which* setting changed, git diff stays meaningful, and the file remains valid YAML that tools can parse.

Internally SOPS uses envelope encryption. For each file it generates a random data key, encrypts every value with it using AES-256-GCM (a fast, tamper-evident cipher), then encrypts the data key itself with one or more master keys — an age keypair, AWS KMS, GCP KMS, Azure Key Vault, or HashiCorp Vault. age is the padlock from the analogy in software form: a deliberately tiny encryption tool whose public keys are short strings starting with age1. The wrapped data key, recipient list, and a MAC (a cryptographic seal that detects tampering) live in a sops: block at the bottom of the file — the file carries everything needed to decrypt itself, *given the right private key*.

A working SOPS setup in four commands

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

The .sops.yaml file is the control plane: it pins *which* public keys may encrypt *which* paths, so nobody pastes recipients by hand. Add a teammate by appending their age1... key and running sops updatekeys db.secrets.yaml — SOPS re-wraps the data key for the new recipient without touching the values.

In CI, set SOPS_AGE_KEY_FILE (or SOPS_AGE_KEY with the key contents) and decrypt during deployment. Better still, avoid writing plaintext to disk at all: sops exec-env db.secrets.yaml 'terraform apply' decrypts into the environment of a single process, which vanishes when the command exits.

sealed-secrets: the cluster holds the key

sealed-secrets (from Bitnami Labs) attacks the same problem but only for Kubernetes, with a different keyholder: *the cluster itself*. A controller — a small program running inside the cluster — generates the keypair and never exposes the private half. The kubeseal CLI fetches the public certificate and encrypts an ordinary Secret manifest into a SealedSecret, a custom resource (a new object type registered with the Kubernetes API). When you apply it, the controller decrypts it *in-cluster* and creates the native Secret. 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 + need a decrypt audit trail
SOPS (+ KMS)
Feeds Terraform, CI, docker-compose, k8s; cloud IAM answers who decrypted — but every human and pipeline needs a private key to distribute
Kubernetes-only, want zero key distribution
sealed-secrets
Devs touch only the public cert; controller holds the key. Ciphertext is per-cluster, no decrypt audit, and the sealing-key backup is do-or-die
Need rotation / central revocation / 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.

By default a SealedSecret is strict-scoped: its name and namespace are baked into the encryption, so the ciphertext for db-creds in prod cannot be renamed or replayed into another namespace by someone with repo access. Keep that default. --scope namespace-wide and --scope cluster-wide trade the protection away for convenience and are rarely worth it.

Back up the sealing key or lose everything
The controller's private key exists only inside the cluster. If the cluster is deleted — or kube-system is wiped — without a backup, every SealedSecret in git becomes permanently undecryptable. There is no recovery path. Export it now: kubectl get secret -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml > sealing-keys.yaml, and store that file in a vault, never in git. Note also that the controller mints a new sealing key every 30 days, but that only affects *new* seals — it does not re-encrypt old ciphertext or rotate the secrets themselves.

Trade-offs: picking between them

SOPS is format- and platform-agnostic: the same encrypted file feeds Terraform, docker-compose, Ansible, CI, and Kubernetes (Flux decrypts SOPS natively; Argo CD needs a plugin). With KMS master keys you inherit cloud IAM and audit logs, so you can answer *who decrypted this, and when*. The cost is key distribution: every human and pipeline that decrypts needs a private key, and offboarding someone means removing their recipient, running sops updatekeys — and rotating the underlying secrets, because they already saw the plaintext.

sealed-secrets inverts that: there is nothing to distribute, because developers only ever touch the public cert. But it is Kubernetes-only, ciphertext is per-cluster (a secret sealed for staging must be re-sealed for prod, and again for every new cluster), there is no audit trail of decryption, and disaster recovery hinges entirely on that key backup.

Both share one honest limitation: they encrypt *static files*. No dynamic credentials, no automatic rotation, no per-application access policies, no central revocation. At five services and one team that trade is excellent — near-zero infrastructure to run. At two hundred services, re-encrypting on every departure or re-sealing on every cluster build becomes the bottleneck, and "rotate a leaked credential" means a commit to every affected repo. Guardrails either way: pin recipients in .sops.yaml, add keys.txt to your global gitignore, add a pre-commit hook that rejects any *.secrets.yaml missing a sops: block, and treat sealing-key backups as crown jewels.

That limitation is the doorway to the final lesson. Encrypted files answer *how do I store a secret in git safely*; they cannot answer *who used it, when, and how do I revoke it in one place*. Centralized secret managers — Vault, the cloud-native stores, and their kin — answer exactly those questions, at the price of running more infrastructure. Next: a decision framework for when files stop being enough.

Quick check
01A developer who held a SOPS age private key leaves your team. You remove their age recipient from .sops.yaml and run sops updatekeys on every encrypted file. Why is the secret still not actually safe?
Incorrect — Tempting but wrong: it does revoke future access, yet the person already read the plaintext, so the credential itself is compromised.
Correct — the lesson states offboarding means removing the recipient, running sops updatekeys, AND rotating the underlying secret, because they already saw the plaintext.
Incorrect — the lesson adds and removes age recipients with updatekeys; it is not KMS-only.
Incorrect — the 30-day key renewal belongs to the sealed-secrets controller, not SOPS, and it only affects new seals anyway.

Related