SOPS + age — Secrets Management for p24-infra

Overview

p24-infra infrastructure secrets (monitoring stack, n8n/bms-4, vps-h1) are managed with SOPS (Secrets OPerationS) + age encryption. Encrypted dotenv files live in git (secrets/*.env.sops). There is no runtime dependency on Infisical CE for these services.

Migrated 2026-06-20 from Infisical CE (monitoring/n8n-bms4/vps-h1). Migrated 2026-06-21 — Infisical CE fully decommissioned; all remaining projects (bms-servers, art-agency, radekkonarski-brand, brandpilot, github-actions) moved to SOPS+age or GH Secrets.


Encrypted files

FileServer / scopeServices / secrets
secrets/monitoring.env.sopsvps-i1Prometheus, Grafana, Alertmanager, exporters, Caddy
secrets/n8n-bms4.env.sopsbms-4n8n queue-mode + workers, Redis, Traefik
secrets/vps-h1.env.sopsvps-h1WAHA gateway, Traefik
secrets/bms-servers.env.sopsoperator local onlyBMS-1/2/3 root passwords, MongoDB credentials
secrets/art-agency.env.sopsArt-Agency appSupabase, PayPal, KDP, Google Drive credentials

Key locations

KeyPathWho holds it
Developer private keyC:\Users\konar\.age\p24-infra-keys.txtLocal dev workstation only
CI private keyC:\Users\konar\.age\p24-infra-ci-keys.txtLocal + GH Secret AGE_KEY_GHA
Standard SOPS path~/.sops/age/keys.txtVPS agents (if key distributed)

Two age public keys are listed as recipients in .sops.yaml — both can decrypt all secrets/*.env.sops files.

AGE_KEY_GHA note: The GH Secret must contain only the AGE-SECRET-KEY-1... line (74 chars), not the full key file with comment headers. Set with:

$key = (Get-Content "C:\Users\konar\.age\p24-infra-ci-keys.txt" | Where-Object { $_ -match '^AGE-SECRET-KEY-1' })
gh secret set AGE_KEY_GHA --repo radieu/p24-infra --body $key

Common operations

Decrypt a file locally

sops --decrypt secrets/monitoring.env.sops
# or to a temp file for docker compose:
sops --decrypt --input-type dotenv --output-type dotenv secrets/monitoring.env.sops > /tmp/monitoring.env

Requires the developer private key at ~/.sops/age/keys.txt (or set SOPS_AGE_KEY_FILE).

Add a new secret

bash (Linux / CI):

export SOPS_AGE_KEY_FILE="$HOME/.age/p24-infra-keys.txt"
sops --decrypt --input-type dotenv --output-type dotenv secrets/monitoring.env.sops > /tmp/monitoring.env
echo "NEW_KEY=value" >> /tmp/monitoring.env
sops --encrypt --input-type dotenv --output-type dotenv /tmp/monitoring.env > secrets/monitoring.env.sops
rm /tmp/monitoring.env
git add secrets/monitoring.env.sops
git commit -m "feat(secrets): add NEW_KEY to monitoring"

PowerShell (Windows dev workstation) — three Windows-specific gotchas:

  • Temp file must live inside secrets/ and end in .env.sops to match .sops.yaml path_regex
  • Write encrypted output with LF-only — Set-Content adds CRLF which breaks SOPS timestamp parsing
  • Write plaintext temp file with UTF8Encoding($false) (no BOM) — BOM on byte 1 corrupts the first key name when the decrypted file is later consumed by other tools
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
 
# Read decrypted content and write WITHOUT BOM (UTF8NoBOM)
$plain = sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops
$plain += "`nNEW_KEY=value"
[System.IO.File]::WriteAllText("$PWD\secrets\monitoring-edit.env.sops",
  ($plain -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
 
# Encrypt (output also written without BOM / with LF-only)
$enc = sops --encrypt --input-type dotenv --output-type dotenv secrets\monitoring-edit.env.sops
[System.IO.File]::WriteAllText("$PWD\secrets\monitoring.env.sops", ($enc -join "`n") + "`n",
  [System.Text.UTF8Encoding]::new($false))
Remove-Item secrets\monitoring-edit.env.sops -Force
git add secrets/monitoring.env.sops
git commit -m "feat(secrets): add NEW_KEY to monitoring"

Rotate an existing secret

Same as adding. After updating the .env.sops file, push to dev → the secrets-sync.yml GitHub Actions workflow automatically decrypts and deploys to the affected server.

Key rotation (age keypair)

Do NOT use sops updatekeys here. It is broken on dotenv *.env.sops files in SOPS 3.9.1 and no flag combination fixes it (issue #4601 — it picks its store from the file extension and ignores --input-type). Re-key with the no-op rewrite below instead; see playbooks/sops-edit-operations.md §“sops updatekeys is unusable on dotenv files”.

# 1. Generate new keypair
age-keygen -o new-key.txt
 
# 2. Add the new public key to the .sops.yaml recipients
# 3. Re-encrypt every file under the updated recipient set (pwsh, repo root).
#    -RekeyOnly = decrypt -> change nothing -> re-encrypt under the current creation_rule,
#    with canary decrypt + recipient-count verification and rollback on failure.
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
Get-ChildItem secrets\*.env.sops | ForEach-Object {
    .\scripts\sops-set.ps1 -SopsFile $_.FullName -RekeyOnly
}
# 4. Commit the updated .env.sops files + .sops.yaml
# 5. Distribute the new private key (update AGE_KEY_GHA, VPS ~/.sops/age/keys.txt)
# 6. Remove the OLD public key from .sops.yaml, then repeat step 3 to drop it from every file

GitHub Actions workflow (secrets-sync.yml)

The secrets-sync.yml workflow:

  1. Checks out the repo
  2. Installs age and sops
  3. Writes AGE_KEY_GHA to $RUNNER_TEMP/age-key.txt
  4. Decrypts with sops --decrypt --input-type dotenv --output-type dotenv
  5. SSHs to each VPS and writes the decrypted .env file
  6. Restarts only affected containers via docker compose --env-file .env up -d --no-deps

Trigger: push to main or dev when any secrets/*.env.sops file changes.


VPS deployment fallback

If the GHA workflow is unavailable, deploy manually:

# On local dev (requires developer private key)
sops --decrypt --input-type dotenv --output-type dotenv secrets/monitoring.env.sops \
  | ssh root@217.154.82.162 "cat > /opt/p24-infra/monitoring/.env.bak"
ssh root@217.154.82.162 "cd /opt/p24-infra/monitoring && docker compose --env-file .env.bak up -d"

The .env.bak on each server is the already-decrypted copy written at deploy time. It is the local fallback when git+SOPS is unavailable.


Infisical CE — decommissioned 2026-06-21

All projects migrated to SOPS+age. Infisical CE containers stopped on vps-i1.

Former projectMigrated to
p24-monitoringsecrets/monitoring.env.sops
n8n-bms4secrets/n8n-bms4.env.sops
vps-h1secrets/vps-h1.env.sops
radekkonarski-brandsecrets/radekkonarski-brand.env.sops (in radekkonarski-personal-brand repo)
brandpilotsecrets/brandpilot.env.sops
bms-serverssecrets/bms-servers.env.sops
art-agencysecrets/art-agency.env.sops
github-actionsGH Secrets directly (no Infisical sync)