Documentation Index
Fetch the complete documentation index at: https://jacobpevans-docs-automation-surface.mintlify.app/llms.txt
Use this file to discover all available pages before exploring further.
SOPS is the default for sensitive-but-not-mission-critical values that live inside a single repo. Doppler holds anything an external system could weaponize. Keychain holds anything that must never touch disk.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” — a randomly generated local-service password, a per-host Ansible variable that names an internal interface, an LXC root password that’s already wrapped behind WireGuard plus key auth — that value is a fine fit for SOPS. Use it. Default to SOPS for as much repo-local config as possible; the encrypted-at-rest property is free once age is set up.
- 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 three-way split
| Tier | Holds | Mechanism |
|---|---|---|
| Encrypted-at-rest in git (low blast radius) | Repo-local generated passwords, per-host vars, structured config the same repo consumes | SOPS + age |
| Rotating or cross-system credentials | External API tokens, HEC tokens, license keys, anything a third party treats as a bearer | Doppler |
| Local-only material (control-plane / never-on-disk) | macOS-side AI tool secrets, master/admin credentials, anything that must never reach a shell history | macOS Keychain |
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 decrypts its ownsecrets.sops.yml, Terragrunt reads its own .sops.json, nothing else.
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:
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 Terraform repos
terraform-proxmox is the canonical example: a .sops.json under each environment folder, listed in .sops.yaml with the age recipient that controls it. Terragrunt consumes it through the sops_decrypt_file data source; resolved values land in Terraform state encrypted by the state backend’s KMS key, not on disk.
terraform-runs-on uses the same mechanism but keeps the encrypted file smaller — it leans on Doppler for AWS creds and reserves SOPS for bootstrap config that doesn’t change.
The .sops.yaml configuration
Each repo declares which paths to encrypt with which keys at the repo root:
~/.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 is192.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:Add the new public recipient to every .sops.yaml
Keep the old recipient until cutover so neither party loses access mid-rotation.
Re-encrypt every SOPS file with both recipients
sops updatekeys .sops.json per file. The encrypted file changes; the plaintext doesn’t.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.