Encrypt secrets in Git with SOPS and age
Keep encrypted secrets safely in your repo, decrypt them in CI, and never commit a plaintext credential again.
GitOps wants every config file in the repo. Secrets are config files too — and putting a database password in plaintext is how you turn a private repository into a slow-motion breach. SOPS (Secrets OPerationS) encrypts only the values in YAML or JSON, leaving keys readable so diffs stay reviewable. Pair it with age (a small modern encryption tool from Filippo Valsorda) and you get envelope encryption without a KMS bill, without a key server, and without pretending base64 is security.
This walkthrough generates an age keypair, wires .sops.yaml so every file under secrets/ encrypts to the same recipients, encrypts a sample file in place, and decrypts it in CI the way a GitOps controller would. If you are still deciding where secrets should live, start with Secrets management foundations — SOPS is the locked drawer, not the staffed key desk.
SOPS generates one data key per file, encrypts values with it, then wraps that data key for each age recipient. The sops: metadata block at the bottom is plaintext — that is by design.
Generate an age keypair and keep the private half out of Git
Each person or CI job that decrypts needs its own age private key. The public key goes in .sops.yaml; the private key lives in a password manager, CI variable, or cluster Secret — never in the repository. Rotating means generating a new key, re-encrypting files with both recipients briefly, then dropping the old recipient.
age-keygen -o ~/.config/sops/age/keys.txtPublic key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8...chmod 600 keys.txt — treat it like a passwordgrep age1 ~/.config/sops/age/keys.txtcopy the public key into .sops.yaml onlyOne .sops.yaml for the whole repo
Creation rules match file paths to recipient lists. You can scope different folders to different teams — platform ops decrypts secrets/infra/, app teams decrypt secrets/apps/shop/ — without maintaining separate encryption scripts.
creation_rules:- path_regex: secrets/.*\.yaml$encrypted_regex: '^(data|stringData|password|token|secret)$'age: >-age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8...,age1another_teammate_public_key...
Encrypt in place and verify the diff
Run sops -e -i before the first commit of a secrets file. After encryption, keys like db_password stay readable in the diff; only the value becomes an ENC[...] blob. That is what makes encrypted secrets reviewable in pull requests.
sops -e -i secrets/prod/database.yamlgit diff secrets/prod/database.yamldb_password: ENC[AES256_GCM,data:9f2a...,type:str]key readable, value ciphertext — safe to commitsops -d secrets/prod/database.yaml | yq .db_passworddecrypt locally only when you need to edit a valueDecrypt in CI without writing secrets back to disk in Git
Export the age private key as SOPS_AGE_KEY from your CI secret store — GitLab masked variable, GitHub Actions secret, whatever you already trust. Decrypt to a temp file, apply, delete. For Kubernetes GitOps, Flux and Argo CD both have SOPS integrations that decrypt at reconcile time so the controller holds the key, not every developer laptop.
deploy:variables:SOPS_AGE_KEY: $AGE_PRIVATE_KEY # from CI secret store, never in reposcript:- export SOPS_AGE_KEY- sops -d secrets/prod/database.yaml > /tmp/db.yaml- kubectl apply -f /tmp/db.yaml- shred -u /tmp/db.yaml
Where this goes next
SOPS solves storage in Git. It does not tell you who read a secret, rotate it on a schedule, or issue short-lived database credentials. When you outgrow encrypted files, move the source of truth to Vault and sync into the cluster with External Secrets. The SOPS course covers KMS backends, multi-recipient rotation, and Flux/Argo integration end to end.
Go deeper in a courseSOPSEncrypt secrets in Git with SOPS, age, and cloud KMS backends.View course