5.4 KiB
Secrets
SOPS-in-repo with one age key per host. Encrypted files live next to docker-compose.yml in each Charlie/<stack> repo. The reusable deploy-stack.yml workflow SSHes into the target host as sys-gitea-runner and runs sops --decrypt there using /etc/charlie/age.key.
Operational recipe: onboarding.md → Host age key (SOPS).
Caller-facing reference: automation.md.
Goal
Every host can:
- Obtain only the secrets it's allowed to decrypt.
- Materialize them where compose can consume them (typically
.env). - Do this non-interactively from a Gitea Actions runner.
Constraints
- Source of truth lives with the stack. A secret for
mediacomposebelongs inCharlie/mediacompose, not in a separate vault. Blast radius is bounded by repo access. - Per-machine decryption. A compromised host leaks only the secrets in repos whose
.sops.yamllists that host's key. - Self-identifying host.
hostname -s | tr '[:upper:]' '[:lower:]'everywhere. - Versioning is git. Rotation = commit + (when needed)
sops updatekeys+ push.
Layout in a stack repo
Charlie/<stack>/
├── docker-compose.yml
├── .env.example # tracked, redacted
├── secrets/
│ └── env.sops.yaml # encrypted; decrypts to .env
├── .sops.yaml # creation rules: which recipients can decrypt
└── .gitea/workflows/deploy.yaml # calls deploy-stack.yml
For shared stacks, .sops.yaml lists every host that runs the stack.
Keys
- One age key per host, at
/etc/charlie/age.key(0640 root:charlie-deploy). Public keys inhosts.md. - One admin key held by alex on the admin laptop, listed in every repo's
.sops.yamlso secrets stay decryptable from off-host. Backup as a vaultwarden Secure Note (Charlie SOPS admin age key). - Per-host, not per-stack. Container isolation already provides the per-stack property —
/etc/charlie/age.keyisn't bind-mounted into containers.
Rotation
# In the stack repo on the admin laptop.
tmp=$(mktemp) && chmod 600 "$tmp"
printf 'KEY=new-value\n' > "$tmp"
sops --encrypt --input-type dotenv --output-type yaml \
--filename-override secrets/env.sops.yaml "$tmp" > secrets/env.sops.yaml
shred -u "$tmp"
git commit -am 'Rotate <KEY>'
git push # triggers redeploy
For per-host secret files, repeat per host.
Adding/removing recipients (e.g. onboarding mohg): edit .sops.yaml, then sops updatekeys secrets/env.sops.yaml.
Registry credentials (read-only PAT)
deploy-stack.yml runs docker login gitea.alexandru.macocian.me on the target host immediately before docker compose pull. This is independent of any creds cached in ~/.docker/config.json — necessary because build-image.yml's cleanup wipes the RW caller PAT after each build, which otherwise leaves the next deploy stranded.
The login uses a dedicated read-only PAT (scope: read:package) belonging to sys-gitea-runner. A host compromise leaks pull access only; pushes still require the per-namespace RW PATs that callers inject into build-image.yml.
The ciphertext lives inlined in deploy-stack.yml between SOPS_BLOB markers in the Registry login step. Recipients = every host age key + the admin key (see /.sops.yaml). Decryption happens on the target host using its /etc/charlie/age.key.
Encrypt / paste recipe
On the admin laptop, in this repo:
mkdir -p secrets
tmp=$(mktemp) && chmod 600 "$tmp"
printf 'REGISTRY_USER=sys-gitea-runner\nREGISTRY_PAT=<ro-pat>\n' > "$tmp"
sops --encrypt --input-type dotenv --output-type yaml \
--filename-override secrets/registry-creds.sops.yaml \
"$tmp" > secrets/registry-creds.sops.yaml
shred -u "$tmp"
Open the resulting secrets/registry-creds.sops.yaml, copy its full contents, and paste it into .gitea/workflows/deploy-stack.yml replacing the REPLACE_ME_WITH_ENCRYPTED_YAML line (preserve the surrounding comment markers). The secrets/registry-creds.sops.yaml file itself is not committed (it's a scratch artifact) — only the inlined paste is the source of truth. git commit -am 'Rotate registry RO PAT' && git push.
Rotation
Regenerate the PAT in Gitea (sys-gitea-runner → Settings → Applications → tokens, read:package only), repeat the recipe, repaste, push. Next deploy on every host picks it up automatically.
Adding a host
When a new host gets an age key during onboarding, add its pubkey to the creation_rules block in /.sops.yaml, then sops updatekeys secrets/registry-creds.sops.yaml (regenerate locally first via the recipe above), repaste, push.
What this design intentionally doesn't do
- Manage secrets for stacks that aren't runner-deployed.
- Replace vaultwarden's human-facing UX. Vaultwarden stays for browser autofill + secure notes (including the admin age key backup).
- Solve secrets for
Charlie/giteaauto-deploy — gitea-on-melina is deployed manually (it bootstraps the runners that would deploy it). SOPS is offline crypto though, so its secrets can live encrypted in the repo and a manualsops -d | docker compose up -dworks.