pez-infra/docs/secrets.md
Rasmus Wejlgaard 361133ec7e docs: catch up with the Cloudflare to Hetzner DNS move, fix secrets/terraform drift
The docs still described Cloudflare as DNS + CDN in front of helsinki-a,
but that was dropped in #90 - pez.sh lives on Hetzner DNS via Terraform
now and records point straight at the origin. Updated README,
architecture, networking, getting-started and the nuremberg-a host doc
to match, and noted that pez.solutions still resolves via Cloudflare
outside Terraform.

Also fixed while I was in there:
- terraform/README: PagerDuty provider is ~> 3.32 (table said ~> 2.2),
  and the B2 secret keys are backblaze_keyID/backblaze_applicationKey
- secrets docs: group_vars secrets file is .enc.yaml, dropped the
  FreeBSD install steps, the long-gone .sops.yaml placeholder note and
  the ANSIBLE_VAULT_PASS migration note, swapped the cloudflare_record
  example for hcloud
- getting-started referenced ansible/scripts/sops-setup.sh which
  doesn't exist
- added naveen.pez.sh to the subdomain tables and a note about the
  DNS-only records (mail, minecraft, wow, public)
2026-06-10 19:35:53 +01:00

4.8 KiB

Secrets Management

This repo uses SOPS with age encryption for secrets. Encrypted files live in the repo alongside the configs they belong to — only the secret values are encrypted, so diffs remain useful.

Why SOPS + age?

  • age over GPG: No key expiry, no keyservers, no UID headaches. A single static public key per recipient.
  • SOPS over git-crypt: Encrypts values, not whole files. You can see the structure of a secrets file without decrypting it. Works with YAML, JSON, ENV, and INI.
  • SOPS over Ansible Vault: Ansible Vault only works with Ansible. SOPS works everywhere — Terraform (via terraform-provider-sops), Docker env files, CI pipelines, scripts.

File naming convention

Encrypted files use .enc. in their extension:

services/authelia/config.enc.yml     # encrypted YAML
services/<service>/<file>.enc.env    # encrypted env file (convention)
terraform/secrets.enc.yaml           # encrypted Terraform vars
ansible/group_vars/all/secrets.enc.yaml

Plaintext files MUST NOT contain secrets. The .gitignore blocks common secret filenames (secrets.yml, vault.yml, secret.env, etc.) as a safety net.

Setup (one-time)

Install tools

# macOS
brew install sops age

# Debian/Ubuntu
apt install age
# SOPS: download from https://github.com/getsops/sops/releases
wget https://github.com/getsops/sops/releases/download/v3.9.4/sops_3.9.4_amd64.deb
dpkg -i sops_3.9.4_amd64.deb

Generate your age key

age-keygen -o ~/.config/sops/age/keys.txt
# Output: public key: age1abc123...

This file is your private key. Never commit it. The .gitignore already blocks keys.txt and *.agekey.

SOPS automatically looks for keys in ~/.config/sops/age/keys.txt (Linux/macOS) or you can set SOPS_AGE_KEY_FILE to point elsewhere.

Add your public key to .sops.yaml

Add your public key to the creation_rules in .sops.yaml, re-encrypt the affected files (see "Add a new recipient" below), and commit the updated .sops.yaml.

Day-to-day usage

Create a new encrypted file

# SOPS picks the right age keys from .sops.yaml based on file path
sops services/authelia/config.enc.yml
# Opens your $EDITOR with a decrypted view. Save and quit to encrypt.

Edit an existing encrypted file

sops services/authelia/config.enc.yml

Decrypt to stdout (for scripts/debugging)

sops -d services/authelia/config.enc.yml

Encrypt an existing plaintext file

# If you have a plaintext file you want to encrypt in-place:
sops -e -i services/<service>/<file>.enc.env

Add a new recipient

When someone new needs access (or a new CI key is generated):

  1. Get their age public key
  2. Add it to the relevant creation_rules in .sops.yaml
  3. Re-encrypt all affected files:
# Update keys on all encrypted files
find . -name '*.enc.*' -exec sops updatekeys {} \;

CI / GitHub Actions

The CI runner needs to decrypt secrets during deploys. Store the age secret key as a GitHub Actions secret:

  1. Generate a CI-specific age key: age-keygen
  2. Add the private key (the AGE-SECRET-KEY-1... line) as a GitHub repository secret named AGE_SECRET_KEY
  3. Add the public key to .sops.yaml (the CI recipient)

In the workflow:

- name: Decrypt secrets
  env:
    SOPS_AGE_KEY: ${{ secrets.AGE_SECRET_KEY }}
  run: |
    sops -d ansible/group_vars/all/secrets.enc.yaml > ansible/group_vars/all/secrets.yml

Terraform integration

Use the terraform-provider-sops to read encrypted values directly:

provider "sops" {}

data "sops_file" "secrets" {
  source_file = "secrets.enc.yaml"
}

# Use decrypted values
provider "hcloud" {
  token = data.sops_file.secrets.data["hetzner_token"]
}

What gets encrypted

These are the types of secrets expected in this repo:

Category Example Location
Ansible group vars SSH keys, API tokens, passwords ansible/group_vars/all/secrets.enc.yaml
Docker env files DB passwords, app secrets services/*/service.enc.env
Terraform vars Hetzner token, Grafana Cloud tokens, B2 keys terraform/secrets.enc.yaml
Service configs Authelia JWT secret, LLDAP admin pass services/*/config.enc.yml

Security notes

  • Never commit keys.txt or any file containing AGE-SECRET-KEY. The .gitignore blocks these.
  • Rotate keys if a machine is compromised: generate new key, update .sops.yaml, re-encrypt all files, revoke the old key from .sops.yaml.
  • CI key is separate from personal keys so it can be rotated independently.
  • SOPS encrypted files contain metadata about which keys can decrypt them — this is intentional and not a secret.