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.
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
# 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
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.
# 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
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.
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.
.sops.yaml and run sops updatekeys on every encrypted file. Why is the secret still not actually safe?sops updatekeys, AND rotating the underlying secret, because they already saw the plaintext.age recipients with updatekeys; it is not KMS-only.