SOPS is the single-repo exception, not the default. OpenBao (T2) is the default runtime store; SOPS holds only a value used by exactly one repo that is neither mission-critical nor a public-service credential.
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, for exampleterraform.sops.json or secrets.enc.yaml. It 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 configuration
file below).
It is not a vault. OpenBao (T2) is. SOPS is checked-in, encrypted-at-rest configuration for the
narrow single-repo case. Examples include OpenTofu variables that name internal networks, and
Ansible variables that vary per host but aren’t truly secret-secret. Control-plane credentials
(Proxmox/iDRAC/network) are not SOPS material. They live in OpenBao secret-store/PATH* (or,
human-only, the Keychain).
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 the runtime manager, or an engine that mints them per call. - 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
1
Edit plaintext
The author edits the plaintext source.
2
Encrypt with sops
sops -e --in-place re-encrypts every value declared in .sops.yaml.3
Commit the encrypted file
The committed
.sops.json keeps metadata (key fingerprints, file hash) readable; values stay encrypted in git history forever.4
Decrypt at runtime
sops -d (often via OpenTofu/Ansible glue) yields plaintext into a subprocess only. Nothing persists to disk.SOPS configuration file
Each repo that uses SOPS has a.sops.yaml declaring which paths get encrypted with which keys:
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 two tiers. If it rotates, it belongs in the runtime manager (T2). If it never rotates and matters to one repo only, SOPS.
Anti-pattern we don’t ship
A plaintext “starter” config liketerraform.tfvars.example that gets renamed to terraform.tfvars
and accidentally committed. The safe pattern: ship terraform.sops.json.example (already
encryption-shaped); the rename-and-commit only ever produces an encrypted file.
See also
- OpenBao: for values that rotate.
- Bitwarden: where age private keys are escrowed.
tofu-proxmox: canonical example of.sops.yaml+ pre-commit + editing workflow.