Files
jakman85andClaude Opus 4.8 84a2d762f0 Phase 1 (author-only): Flux + SOPS bootstrap + interface wiring (T-1.4)
bootstrap/sops (age-keygen refusing in-repo, .sops.yaml, install-age-key.sh
glue installing the sops-age Secret) + bootstrap/flux (bootstrap.sh: flux
install -> apply -k clusters/dev; decryption/url patches). PM wiring: place
.sops.yaml at repo root (SOPS walks to root); inline the 3 flux patches into
clusters/dev (avoids kustomize ../ load-restriction). clusters/dev builds with
decryption enabled; full CI gate PASS. No key material; nothing executed.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-16 01:21:01 -05:00

3.5 KiB

Installing the age private key as the sops-age Secret (out-of-band)

Phase 1, T-1.4 · ADR-0007 (SOPS+age) · R-SEC-01 · spec docs/07 "Secrets management".

This is the one sanctioned non-GitOps action in the secrets model (ADR-0007 Consequences, docs/07): the age private key is bootstrapped into the cluster out-of-band as a Kubernetes Secret that Flux's kustomize-controller reads to decrypt SOPS at reconcile time. The key is never committed (.gitignore guards age.key, *.key); no key material appears in git, PRs, or logs (AGENTS.md §5).

AUTHOR-ONLY runbook. Nothing here has been executed. The operator runs these steps once, after the age key exists (age-keygen.sh) and Flux controllers are installed (../flux/bootstrap.sh).

Prerequisites

  1. age-keygen.sh has been run → private key at ~/.config/sops/age/keys.txt (outside the repo).
  2. The flux-system namespace exists (created by flux install / flux bootstrap).
  3. kubectl context points at the dev cluster.

Create the Secret (command template — NOT run)

Flux discovers age keys inside the sops-age Secret by the .agekey filename suffix, so the data key MUST end in .agekey:

# Reads the private key file and stores it under data key "age.agekey".
cat ~/.config/sops/age/keys.txt | kubectl create secret generic sops-age \
  --namespace=flux-system \
  --from-file=age.agekey=/dev/stdin

Idempotent re-apply variant (avoids "already exists" on re-runs):

kubectl create secret generic sops-age \
  --namespace=flux-system \
  --from-file=age.agekey="$HOME/.config/sops/age/keys.txt" \
  --dry-run=client -o yaml | kubectl apply -f -
  • Secret name sops-age and namespace flux-system MUST match the decryption.secretRef.name on the platform/apps Flux Kustomizations (see ../flux/patches/).
  • This Secret is created imperatively and is NOT in git — it holds the private key, so it must never be committed, encrypted or otherwise. (It is the trust anchor, not a managed resource.)

Verify (read-only — NOT run)

kubectl -n flux-system get secret sops-age                       # exists
kubectl -n flux-system get secret sops-age \
  -o jsonpath='{.data.age\.agekey}' | base64 -d | age-keygen -y  # prints PUBLIC recipient only

The printed public recipient MUST equal the age: value in .sops.yaml. (This command derives the public key from the private key; it never exposes the private key.)

Rotation (documented per ADR-0007 "Key custody/rotation")

  1. Generate a NEW key with age-keygen.sh (to a fresh path; the script refuses to clobber).
  2. Add the new public recipient to .sops.yaml alongside the old (multi-recipient), then sops updatekeys every encrypted file so both keys can decrypt.
  3. Update the sops-age Secret to contain both private keys (two *.agekey data entries) so Flux can still decrypt existing files during the transition.
  4. Once all files are re-encrypted to the new recipient, remove the old recipient from .sops.yaml, sops updatekeys again, and drop the old key from the Secret.

Key custody / DR (ADR-0007: "losing it blocks decryption")

  • The age private key is DR-critical: without it Flux cannot decrypt any committed Secret.
  • Keep an off-cluster backup of keys.txt in the operator's password manager / offline store (NOT in this repo, NOT on the cluster it protects). This mirrors the off-cluster-mirror posture for Gitea (docs/05 "Circularity & DR").