If it can be encrypted and committed, SOPS holds it. If it must rotate, Doppler does.
What SOPS is for
SOPS encrypts the values in a structured config file (YAML, JSON, TOML, dotenv, INI) while leaving the keys readable. The encrypted output is a regular file with structured contents — e.g.terraform.sops.json, secrets.enc.yaml — that lives in git and is decrypted at runtime by the age private key. The repo-root .sops.yaml is a separate, unencrypted configuration file that tells SOPS which paths to encrypt and with which keys (see .sops.yaml configuration below).
It is not a vault. It is checked-in, encrypted-at-rest configuration — perfect for: OpenTofu variables that name internal networks, initial-bootstrap passwords for Proxmox/iDRAC, ansible variables that vary per host but aren’t truly secret-secret.
What does not belong in SOPS
- Live API keys that rotate frequently —
git logkeeps the encrypted history forever, and “encrypted today” is only as strong as the age key. Use Doppler. - SSH keys, recovery codes — these are human-only material; Bitwarden.
- Anything you cannot afford to have in a public-fork forever — encryption is not deletion.
The encrypt / commit / decrypt cycle
Commit the encrypted file
The committed
.sops.json keeps metadata (key fingerprints, file hash) readable; values stay encrypted in git history forever.~/.config/sops/age/keys.txt for convenience; the canonical backup is escrowed in Bitwarden.
.sops.yaml configuration
Each repo that uses SOPS has a .sops.yaml declaring which paths get encrypted with which keys:
~/.config/sops/age/keys.txt (local convenience) and is escrowed in Bitwarden (canonical backup).
Editing an existing SOPS file
sops opens the editor; you edit plaintext; on save it re-encrypts. Never git add an unencrypted copy. Pre-commit hooks (provided in tofu-proxmox and friends) verify every staged .sops.json is actually encrypted.
Rotating the age key
- Generate a new key:
age-keygen -o ~/.config/sops/age/keys.txt.new - Update
.sops.yamlin each affected repo to add the new public recipient (keep the old one until cutover). - Run
sops updatekeys .sops.jsonacross every encrypted file. - Remove the old recipient from
.sops.yaml; runsops updatekeysagain. - Escrow the new key in Bitwarden; revoke the old.
agentsmd/rules/config-secrets.md — keep it in muscle memory for the team.
Best practices
- Escrow the age private key in Bitwarden the moment it is generated. The local file is convenience; the escrow is canonical.
- Use one age key per “trust domain” — homelab gets one key, AWS infra gets another. Compromise of one does not leak the other.
- Pre-commit hook in every repo that uses SOPS to verify staged
.sops.jsonfiles have non-plaintext values. This is the cheapest control. - Never store the same value in both SOPS and Doppler. If it rotates, it belongs in Doppler. If it doesn’t, SOPS.
Anti-pattern we don’t ship
A plaintext “starter” config liketerraform.tfvars.example that gets renamed to terraform.tfvars and accidentally committed. The pattern we use: ship terraform.sops.json.example (already encryption-shaped); the rename-and-commit only ever produces an encrypted file.
See also
- Doppler — for values that rotate.
- Bitwarden — where age private keys are escrowed.
tofu-proxmox— canonical example of.sops.yaml+ pre-commit + editing workflow.