Encrypting Local Secrets at Rest with SOPS and age
Your team needs one shared source of secrets that lives in Git, yet git diff on a committed .env prints DB_PASSWORD=hunter2 in cleartext for anyone with read access. This page gives a deterministic, CLI-driven workflow to encrypt those values at rest with SOPS and age, commit only the ciphertext, and decrypt locally on demand — sitting under provision a local vault and rotate leases and the wider environment sync and CI parity baseline.
The distinction that makes this work is structural: SOPS encrypts only the values in a YAML, JSON, or dotenv file and leaves the keys readable, so a reviewer can still see that DB_PASSWORD changed without ever seeing the value. The ciphertext file is safe to commit and share; the age private key that decrypts it lives on each developer's disk and is never tracked. If you are still choosing between brokers, weigh the trade-offs in Vault vs dotenv-vault vs SOPS for local secrets; if a secret already reached the index in plaintext, first purge it following managing local secrets without committing to Git.
Diagnostic
Confirm the exposure before you fix it. The check is whether a committed secret file is stored as plaintext and whether it decodes to readable values in the current tree:
#!/usr/bin/env bash
set -euo pipefail
git ls-files -- '*.env' 'secrets*.yaml' 'secrets*.json' | while read -r f; do
if grep -qiE '(password|secret|token|api_?key)[=:]' "${f}"; then
echo "PLAINTEXT SECRET IN GIT: ${f}"
fi
done
git show HEAD:secrets.enc.yaml 2>/dev/null | grep -q 'sops:' \
|| echo "NO SOPS METADATA: secrets.enc.yaml is not encrypted"
Expected BAD output — the value is committed in the clear and no encrypted file exists yet:
PLAINTEXT SECRET IN GIT: config/secrets.yaml
NO SOPS METADATA: secrets.enc.yaml is not encrypted
The first loop lists every tracked candidate file and flags any that still contain a key=value or key: value line matching a secret-like name — those are readable by anyone who can clone. The second command looks for the sops: metadata block that a correctly encrypted file always carries at its tail; its absence means the file is either missing or plaintext. A properly SOPS-managed file inverts both signals: the grep finds no readable secret substrings because every value is a base64 ciphertext, and the sops: block is present. Run this as a read-only audit first so you know exactly which files need migrating before you touch any keys.
Root cause
A plaintext secret in Git is not a mistake in any single command — it is the default outcome of treating a secrets file like ordinary source. Git stores files verbatim, so whatever bytes you git add are what every clone receives, forever, in history. There is no built-in confidentiality layer: read access to the repository is read access to the secret. Teams reach for a committed .env because it is the simplest way to share configuration, but that convenience is exactly what leaks the value to every collaborator, CI runner, and fork.
There is a second, subtler failure that pure ignore rules never solve: even if you keep the current secret out of the tree, the moment someone needs to share an update, the plaintext has to travel through some side channel — a paste in chat, a copied file, a wiki page — and each of those is an uncontrolled copy that outlives the rotation. A committed ciphertext removes the side channel entirely, because the canonical shared artifact is the encrypted file itself and the update path is an ordinary reviewed commit.
Encryption at rest closes the gap by moving the trust boundary off Git entirely. With SOPS and age, the repository holds only ciphertext, and the ability to read it depends on possessing a private key that was never committed. age is the deliberate choice over raw GPG here because its keypairs are a single short line with no keyring, no trust database, and no subkey ceremony, which makes per-developer key generation a one-command step rather than a support burden. The keys stay readable in the encrypted file precisely so that structural review still works — a diff shows which secret rotated without disclosing the new value. The problem the workflow below solves is therefore not "how do I hide the file" but "how do I encrypt the values, distribute decryption to exactly the right people, and make local decryption a single reproducible command."
Resolution
- Generate an
agekeypair per developer and store the private key outside any repo:#!/usr/bin/env bash set -euo pipefail mkdir -p "${HOME}/.config/sops/age" age-keygen -o "${HOME}/.config/sops/age/keys.txt" chmod 600 "${HOME}/.config/sops/age/keys.txt" grep 'public key:' "${HOME}/.config/sops/age/keys.txt" - Record the team's public recipients in a committed
.sops.yamlcreation rule so every new file is encrypted to the whole team automatically:# .sops.yaml — committed; contains only public keys creation_rules: - path_regex: \.enc\.(ya?ml|json|env)$ key_groups: - age: - age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p - age1lggyhqrw2nlhcxprm67z43rta597azn8gk40vgmk9uu5wp5v9x4qsm6kf0 - Encrypt an existing plaintext file in place, then remove the cleartext original from tracking:
#!/usr/bin/env bash set -euo pipefail sops encrypt config/secrets.yaml > config/secrets.enc.yaml git rm --cached config/secrets.yaml echo 'config/secrets.yaml' >> .gitignore git add .sops.yaml config/secrets.enc.yaml .gitignore - Commit the ciphertext and push; the encrypted file is safe to share:
#!/usr/bin/env bash set -euo pipefail git commit -m "chore: encrypt secrets with SOPS + age" git push - On any teammate's machine, decrypt locally with the private key SOPS finds via
SOPS_AGE_KEY_FILE:#!/usr/bin/env bash set -euo pipefail export SOPS_AGE_KEY_FILE="${HOME}/.config/sops/age/keys.txt" sops decrypt config/secrets.enc.yaml
Step 1 produces a keypair where the private half never leaves ~/.config and the public half is what you hand to whoever maintains .sops.yaml. Step 2 is the piece that makes the workflow scale: because the creation rule lists every recipient, sops encrypt on a matching path encrypts to all of them at once, so onboarding a new engineer is a one-line diff to the rule plus a re-encrypt, not a per-file scramble. Step 3 is deliberately two actions — encrypt, then untrack the plaintext — so the readable original stops being committed while the ciphertext takes its place. The .gitignore line stops the plaintext from sneaking back in on a later git add ..
For files consumed as environment variables, encrypt the dotenv form directly and decrypt straight into the process environment so no plaintext ever touches disk — the same injection pattern used when managing local secrets without committing to Git:
#!/usr/bin/env bash
set -euo pipefail
export SOPS_AGE_KEY_FILE="${HOME}/.config/sops/age/keys.txt"
set -a
eval "$(sops decrypt --output-type dotenv config/secrets.enc.env)"
set +a
exec "$@"
Wrap that as a run-with-secrets.sh entrypoint and every command inherits the decrypted variables without a single plaintext byte hitting the filesystem.
Expected output
A correctly encrypted secrets.enc.yaml keeps the keys readable and replaces every value with an ENC[...] ciphertext plus a trailing sops: metadata block:
DB_PASSWORD: ENC[AES256_GCM,data:9kQ2w==,iv:7f...,tag:1a...,type:str]
API_KEY: ENC[AES256_GCM,data:pL8s==,iv:3c...,tag:9d...,type:str]
sops:
age:
- recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
...
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-07-24T10:12:03Z"
mac: ENC[AES256_GCM,data:...]
The sops decrypt from step 5 then prints the original cleartext back to stdout:
DB_PASSWORD: hunter2
API_KEY: sk_live_redacted
If decryption instead fails with no identity matched any of the recipients, the private key at SOPS_AGE_KEY_FILE is not one of the recipients baked into the file — the developer's public key was never added to .sops.yaml, or the file was encrypted before they joined and needs re-encrypting with sops updatekeys config/secrets.enc.yaml.
Prevention
- Add a pre-commit hook that refuses any commit containing an unencrypted match of your secret file pattern, so a plaintext value can never be staged again:
#!/usr/bin/env bash set -euo pipefail for f in $(git diff --cached --name-only --diff-filter=ACM); do case "${f}" in *.enc.yaml|*.enc.json|*.enc.env) grep -q 'sops:' "${f}" || { echo "BLOCKED: ${f} is not SOPS-encrypted" >&2; exit 1; } ;; esac done - Decrypt on shell entry through
direnvso nobody hand-runssops decryptand no plaintext file lingers. Commit an.envrcthat loads the values into the process environment oncd:#!/usr/bin/env bash set -euo pipefail # .envrc — direnv runs this on directory entry export SOPS_AGE_KEY_FILE="${HOME}/.config/sops/age/keys.txt" eval "$(sops decrypt --output-type dotenv config/secrets.enc.env)" - When a developer leaves or a key is suspected leaked, re-key the file rather than editing recipients by hand.
sops updatekeysreads the current.sops.yamland re-encrypts the data key to exactly the listed recipients:#!/usr/bin/env bash set -euo pipefail sops updatekeys config/secrets.enc.yaml git commit -am "chore: rotate SOPS recipients"
These defenses are complementary. The pre-commit hook stops a plaintext regression at the staging boundary, direnv removes the manual decrypt step where a stray plaintext file would otherwise appear, and updatekeys keeps the recipient list honest as the team changes. The onboarding sequence below is what a new engineer runs once; after that, decryption is automatic on every cd into the repo.
Platform caveats
WSL2: Keep
keys.txtinside native ext4 under~/.config/sops/age, not/mnt/c. SOPS reading the age identity across the 9p filesystem bridge is slow and intermittently returns an empty key, which surfaces as a spuriousno identity matchederror on an otherwise valid setup.
macOS (Docker Desktop): When a container decrypts at startup, mount the key read-only (
-v ${HOME}/.config/sops/age:/keys:ro) and setSOPS_AGE_KEY_FILE=/keys/keys.txt; the default Docker Desktop file sharing preserves the600mode, but a bind mount from/tmpmay not, and age refuses a world-readable identity.
Apple Silicon (ARM64): install
sopsandagefrom native ARM Homebrew builds rather than Rosetta x86 binaries. A mismatchedagebuilt for x86 can fail to locate the identity file when invoked from an ARM shell, breakingSOPS_AGE_KEY_FILEresolution.
Rollback
If encryption breaks a workflow and you need the readable file back immediately, decrypt in place and restore tracking — but treat every value as compromised the moment plaintext re-enters Git, and rotate afterward:
#!/usr/bin/env bash
set -euo pipefail
export SOPS_AGE_KEY_FILE="${HOME}/.config/sops/age/keys.txt"
sops decrypt config/secrets.enc.yaml > config/secrets.yaml
echo "Plaintext restored locally — do NOT commit config/secrets.yaml"
The decrypt is instantaneous because SOPS only unwraps one symmetric data key per file regardless of how many values it holds; the age asymmetric step runs once, then AES-GCM decrypts every value. That constant per-file cost is why decryption stays fast as the secret count grows, as the measured timings below show.
Rolling back to plaintext is containment only, not a fix — anyone who pulled the readable commit still has it. After restoring, rotate the affected credentials and, if the plaintext was pushed, purge it following managing local secrets without committing to Git, then re-encrypt the file. For live values that must change without downtime, coordinate the swap using rotating secrets without restarting containers.
Frequently Asked Questions
Why does SOPS leave the YAML keys readable instead of encrypting the whole file?
By design. SOPS encrypts only the values and preserves the structure so a git diff shows which secret changed without disclosing the new value, and so reviewers can approve a rotation on shape alone. The keys are non-secret metadata; the confidentiality is entirely in the ENC[...] value ciphertexts and the trailing sops: block that records which recipients can unwrap the data key.
Is it safe to commit the .sops.yaml file and the encrypted output?
Yes. .sops.yaml contains only the public age recipients and path rules, which are not secret. The secrets.enc.yaml output holds ciphertext plus the public recipient list and a MAC — no plaintext and no private key. The only material that must stay off Git is each developer's private keys.txt, which lives at SOPS_AGE_KEY_FILE and is never tracked.
How do I add a new teammate without re-encrypting every value by hand?
Add their public age key to the age: list in .sops.yaml, then run sops updatekeys config/secrets.enc.yaml. That re-encrypts only the symmetric data key to the new recipient set and leaves the value ciphertexts untouched, so it is fast even for large files. Commit the result; the new developer can now decrypt with their own private key.
What happens if a developer loses their private age key?
They can no longer decrypt, but nothing is exposed — the ciphertext is unreadable without a listed private key. Have them generate a fresh keypair with age-keygen, replace their old public key in .sops.yaml, and run sops updatekeys to re-key the file. If the lost key could have been captured by someone else, rotate the underlying credentials as well, since the old key still decrypts any pre-rotation ciphertext.