Skip to main content
OpenBao (T2) is the default runtime store — anything a service, human, or agent might ever need lives there. SOPS is the narrow exception: a value used by exactly one repo that is neither mission-critical nor a public-service credential. Doppler holds secret-zero and cloud/SaaS keys; Keychain holds anything that must never touch disk. See the four-tier model for the canonical policy.
This page is the IaC-flavored cut. For the broader posture (every tool, every tier), see Security overview and the dedicated SOPS tool page.

When SOPS is the right answer

SOPS-encrypted files live inside a single repo and decrypt with that repo’s age recipient. The question to ask before reaching for it is blast radius: if an AI agent (or a hostile reviewer, or a careless paste into a Slack channel) decrypted this file and posted it publicly, what would actually break?
  • If the answer is “nothing meaningful” AND the value is used by only this one repo — a per-host Ansible variable that names an internal interface, structured config the same repo consumes — SOPS is a fine fit. But do not default to SOPS: the moment another service, a human, or an agent might need the value, it belongs in OpenBao (T2), not a repo-local file. SOPS is the exception, not the reach-for-first tool.
  • If the answer is “an attacker can now hit AWS / Doppler / a third-party SaaS” — API tokens, OAuth keys, HEC tokens, license keys, anything an external service treats as a bearer credential — that value does not belong in SOPS. It goes in Doppler.
  • If the answer is “an attacker can now move laterally inside the homelab” — the master Proxmox root password, an iDRAC admin credential, anything that grants control-plane access if exposed — it goes in Keychain, not committed at all.
The mental model is “would a one-time public leak of this exact string be a paper cut, or would it be an incident?” SOPS is for paper cuts.

The four-tier split

Cross-repo: SOPS is for one repo, not many

A single secret should have exactly one source of truth. SOPS works cleanly when the value is consumed only by the repo it’s committed in. Ansible may decrypt its own secrets.sops.yml; Terrakube workspaces obtain runtime secrets from OpenBao instead. When two repos both need the same value, do not SOPS-encrypt it twice. That’s a DRY violation — the two copies will drift the first time one repo rotates without notifying the other, and the only signal you get is a production failure. The decision tree:
  • Repo A is fully dependent on Repo B (B publishes a value, A consumes it, A never rewrites it) → SOPS in repo B is fine; repo A pulls it through B’s output, or through Doppler if the dependency is loose.
  • Repos A and B can both update the value independently → SOPS in neither. Promote the value to Doppler. Both repos read it at runtime; rotation is a single Doppler write.
  • The value is genuinely local to one repo’s CI/test/bootstrap → SOPS in that one repo. No cross-repo concern exists.

How it shows up in Ansible repos

ansible-proxmox, ansible-proxmox-apps, and ansible-splunk all follow the same pattern:
At runtime, sops exec-env secrets.sops.yml -- ansible-playbook ... decrypts into a subprocess env only; nothing persists to disk. Rotating values stay in Doppler, accessed through doppler run -- ansible-playbook .... The two wrappers compose cleanly — Doppler outermost, sops inside.

How it shows up in OpenTofu repos

Active OpenTofu repos do not use SOPS to inject provider credentials. Terrakube supplies workload identity, and OpenBao mints short-lived, workspace-scoped credentials. Terrakube owns remote state and workspace locking; sensitive provider values are ephemeral wherever the provider supports them.

The .sops.yaml configuration

Each repo declares which paths to encrypt with which keys at the repo root:
Only the public half of the age key appears here. The private half lives at ~/.config/sops/age/keys.txt (local convenience) and is escrowed in Bitwarden (canonical backup). The example shown is a placeholder — real public recipients live in each repo’s .sops.yaml.

What you must never commit

Per the secrets policy: no real IPv4 addresses, no real internal hostnames, no real domain names, no AWS account IDs, no SSH keys, no user-specific paths. Use placeholders or variables for anything tied to one user; every committed value should work for any person who clones the repo right now. The committed scrubbed-value table is 192.168.0.*, example.com, example.local, your-token-here, generic role-based usernames.

Rotating the age key

The five-step dance is the same across every repo that uses SOPS:
1

Generate the new key

age-keygen -o ~/.config/sops/age/keys.txt.new. Read-only file, not a service.
2

Add the new public recipient to every .sops.yaml

Keep the old recipient until cutover so neither party loses access mid-rotation.
3

Re-encrypt every SOPS file with both recipients

sops updatekeys .sops.json per file. The encrypted file changes; the plaintext doesn’t.
4

Remove the old recipient

Re-run sops updatekeys once more. Now only the new key can decrypt.
5

Escrow + revoke

New key goes into Bitwarden the moment it’s generated. Revoke the old key in any tooling that referenced it.

See also

Security overview

Which tool for which secret, across every surface.

SOPS tool page

Tool-level docs on SOPS itself — encrypt/decrypt cycle, anti-patterns, escrow.

Doppler

Where rotating runtime secrets live. The other half of this split.

macOS Keychain

Where local-only secrets live. The third tier.