> ## Documentation Index
> Fetch the complete documentation index at: https://docs.jacobpevans.com/llms.txt
> Use this file to discover all available pages before exploring further.

# SOPS for IaC

> SOPS + age for low-blast-radius repo-local config: randomly generated passwords, sensitive-but-not-mission-critical values committed inside the one repo that owns them.

> 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](/security/overview) and the dedicated [SOPS tool page](/security/tools/sops).

## 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 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 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 own `secrets.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`](/infrastructure/repos/ansible-proxmox), [`ansible-proxmox-apps`](/infrastructure/repos/ansible-proxmox-apps), and [`ansible-splunk`](/observability/repos/ansible-splunk) all follow the same pattern:

```yaml theme={null}
# secrets.sops.yml.example — committed
NAS_HOMEASSISTANT_SMB_PASSWORD: change-me
```

```bash theme={null}
cp secrets.sops.yml.example secrets.sops.yml
sops --encrypt --in-place secrets.sops.yml   # encrypts in place
git add secrets.sops.yml                     # safe: values are ciphertext now
```

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

[`tofu-proxmox`](/infrastructure/repos/tofu-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 OpenTofu state encrypted by the state backend's KMS key, not on disk.

[`tofu-runs-on`](/infrastructure/cicd/tofu-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:

```yaml theme={null}
creation_rules:
  - path_regex: \.sops\.json$
    age: >-
      age1aaaa... # public age recipient
```

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:

<Steps>
  <Step title="Generate the new key">
    `age-keygen -o ~/.config/sops/age/keys.txt.new`. Read-only file, not a service.
  </Step>

  <Step title="Add the new public recipient to every .sops.yaml">
    Keep the old recipient until cutover so neither party loses access mid-rotation.
  </Step>

  <Step title="Re-encrypt every SOPS file with both recipients">
    `sops updatekeys .sops.json` per file. The encrypted file changes; the plaintext doesn't.
  </Step>

  <Step title="Remove the old recipient">
    Re-run `sops updatekeys` once more. Now only the new key can decrypt.
  </Step>

  <Step title="Escrow + revoke">
    New key goes into Bitwarden the moment it's generated. Revoke the old key in any tooling that referenced it.
  </Step>
</Steps>

## See also

<CardGroup cols={2}>
  <Card title="Security overview" icon="lock" href="/security/overview">
    Which tool for which secret, across every surface.
  </Card>

  <Card title="SOPS tool page" icon="key" href="/security/tools/sops">
    Tool-level docs on SOPS itself — encrypt/decrypt cycle, anti-patterns, escrow.
  </Card>

  <Card title="Doppler" icon="rotate" href="/security/tools/doppler">
    Where rotating runtime secrets live. The other half of this split.
  </Card>

  <Card title="macOS Keychain" icon="apple" href="/security/tools/macos-keychain">
    Where local-only secrets live. The third tier.
  </Card>
</CardGroup>
