Secrets have to live next to the manifests that use them, or a rebuild from git produces a cluster that
cannot start. Before this decision, that meant ~35 plaintext Secret manifests and several .env
files in git, including a Hetzner API token that could delete the server, a GitHub token with write
access to two organisations, and a Cloudflare Origin CA private key. Deleting a file does not remove it
from history, so the values are public to anyone who ever cloned the repo — and the cluster is the
thing being protected.
A second requirement rules out several convenient designs: ArgoCD must be able to sync unattended, including after a node rebuild, with no human present to unlock anything.
SOPS with age recipients is the only secret mechanism, and ksops is how ArgoCD decrypts it.
age-ops (the owner’s workstation, plus an offline
copy) and age-cluster (installed once as the argocd/sops-age Secret so the repo-server can decrypt
during a sync with nobody present). The private keys are never committed — argocd/sops-age is the
one cluster object that exists only because bootstrap put it there.creation_rules for **/secrets.sops.yaml set
encrypted_regex: ^(data|stringData)$, so kind, apiVersion and metadata stay readable and
diffable and only the values are ciphertext.community.sops vars plugin on the control machine; terraform/secrets.enc.env is read by
sops exec-env so no plaintext credential is ever written to disk.ksops and points
SOPS_AGE_KEY_FILE at the mounted sops-age Secret, so the kustomize build ArgoCD runs for each
Application resolves the real Secret.Secret manifests are structurally prevented: scripts/check-secrets.sh fails if a
file named secrets.sops.yaml lacks a sops: block with age: recipients, or if any Secret under
the manifests carries non-empty values without SOPS metadata. It needs no decrypt key, so CI can run
it.helm-secrets for the Helm values — no longer needed (2026-09-29, SEC-003). It was the candidate
for k8s/apps/vcap/values/*.yaml, on the reasoning that a ksops generator cannot be reached through
kustomize --enable-helm over a git-sourced chart. The blocker was real, but the conclusion was too
strong: ArgoCD multi-source lets the chart stay a Helm source while a second source in this repo
carries the SOPS Secrets, so neither helm-secrets nor publishing the chart was required. See the
known gap below.age key custody is a hard dependency: losing both private keys loses every git-held secret, so an
offline copy is part of the design (not a nice-to-have).secrets.sops.yaml breaks its mac and fails at sync time rather than silently
producing a wrong value — verified by re-reading with sops -d before committing./etc/vps-backup/env from SOPS, so the restic password is never
in a unit file.k8s/apps/vcap/values/backend-{dev,staging}.yaml were
plaintext Helm values carrying credentials, with a matching .gitleaks.toml allowlist. The credentials
moved to k8s/apps/vcap/secrets/{dev,staging}/secrets.sops.yaml (multi-source, see above), the
allowlist is deleted, and gitleaks over the tree with it removed reports no leaks — evidence it was
not hiding anything else. frontend-staging.yaml never carried values, only the name of a Secret it
references..sops.yaml (creation rules), k8s/platform/argocd/values.yaml (the repo-server ksops initContainer,
pinned by digest), k8s/platform/argocd/secret/, scripts/check-secrets.sh, ansible/ansible.cfg
(vars plugin), and docs/secrets.md.