1. sops-age-secrets-migration: Complete guide for migrating from git-crypt to SOPS+age. Covers JSON format requirement, race condition avoidance, CI integration, complex types, and migration sequence. 2. iterative-plan-review-with-subagents: Design pattern for reviewing plans with parallel security + implementation subagents. 2-3 iterations to zero CRITICALs. Used successfully for the SOPS migration design.
4.2 KiB
4.2 KiB
| name | description | author | version | date |
|---|---|---|---|---|
| sops-age-secrets-migration | Migrate from git-crypt to SOPS + age for multi-user secret management in a Terraform/Terragrunt infrastructure repo. Use when: (1) need per-user secret access control (git-crypt is all-or-nothing), (2) want operators to push PRs without seeing secrets (CI decrypts), (3) migrating from a single encrypted terraform.tfvars to structured secret management. Covers: JSON format (not YAML — Terraform can't parse YAML tfvars), race condition avoidance with parallel terragrunt applies, CI pipeline integration with Woodpecker, age key management, and the complete migration sequence. | Claude Code | 1.0.0 | 2026-03-07 |
SOPS + age Secrets Migration from git-crypt
Problem
git-crypt encrypts entire files — anyone with the key decrypts everything. For multi-user setups where operators should push code without seeing secrets, you need per-value encryption with CI-only decryption.
Context / Trigger Conditions
- Single
terraform.tfvarsencrypted with git-crypt containing 100+ secrets - Need to onboard operators who shouldn't see API keys, passwords, SSH keys
- Want GitOps (secrets in git) but with access control
- Terraform/Terragrunt stack-per-service architecture
Solution
1. Use JSON, not YAML
SOPS outputs the same format as input. sops -d file.yaml → YAML. sops -d file.json → JSON.
Terraform natively supports *.auto.tfvars.json files. YAML is NOT valid HCL.
secrets.sops.json → sops -d → secrets.auto.tfvars.json → Terraform reads it
2. Split tfvars into config + secrets
config.tfvars ← plaintext (hostnames, IPs, DNS records)
secrets.sops.json ← SOPS-encrypted (passwords, tokens, keys)
3. Global decrypt, not per-stack hooks
CRITICAL: Do NOT use before_hook/after_hook for decryption. With terragrunt run --all,
70+ stacks run hooks in parallel, all writing to the same output file — race condition.
Instead, use a wrapper script that decrypts once:
#!/usr/bin/env bash
# scripts/tg — decrypt then terragrunt
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
if [ ! -f "$REPO_ROOT/secrets.auto.tfvars.json" ] || \
[ "$REPO_ROOT/secrets.sops.json" -nt "$REPO_ROOT/secrets.auto.tfvars.json" ]; then
sops -d "$REPO_ROOT/secrets.sops.json" > "$REPO_ROOT/secrets.auto.tfvars.json"
fi
exec terragrunt "$@"
4. Terragrunt loads both (backward compatible)
terraform {
extra_arguments "common_vars" {
commands = get_terraform_commands_that_need_vars()
required_var_files = ["${get_repo_root()}/config.tfvars"]
optional_var_files = [
"${get_repo_root()}/terraform.tfvars", # legacy (git-crypt)
"${get_repo_root()}/secrets.auto.tfvars.json" # new (SOPS)
]
}
before_hook "check_secrets" {
commands = ["apply", "plan", "destroy"]
execute = ["test", "-f", "${get_repo_root()}/secrets.auto.tfvars.json"]
}
}
5. Complex types work in JSON
Maps, lists, nested objects, multiline strings (SSH keys as \n-escaped) all work:
{
"simple_password": "abc123",
"mailserver_accounts": {"user@domain": "pass"},
"ssh_key": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3Blbn...\n-----END OPENSSH PRIVATE KEY-----\n"
}
6. CI integration (Woodpecker)
- Store age private key as CI secret (
SOPS_AGE_KEY) - Write to temp file for
SOPS_AGE_KEY_FILE(Woodpeckerfrom_secretonly does env vars) git add stacks/ state/ .woodpecker/— NEVERgit add .- Cleanup step with
status: [success, failure]
Verification
# Encrypt
sops -e -i secrets.sops.json
# Decrypt and verify
sops -d secrets.sops.json | jq .
# Verify SSH keys
sops -d secrets.sops.json | jq -r '.ssh_key' | ssh-keygen -l -f -
# Test with terragrunt
scripts/tg validate
Notes
- Keep git-crypt for binary files (TLS certs, deploy keys) — SOPS can't encrypt binary
sensitive = trueon all secret variable declarations — prevents plan output leaks- Don't add
sensitive = trueto non-secret variables with "secret" in the name (e.g.,tls_secret_name,ingress_path) — breaksfor_eachon lists - Age keys are one line — much simpler than GPG
.sops.yamlpath_regex should be anchored:^secrets\.sops\.json$