Skip to content

ADR-0008: Manage YourBOX Secrets With SOPS And age In Dev, OpenBao And External Secrets In Production

Accepted

YourBOX today receives secrets through three incompatible paths:

  • The CI pipeline builds an image baked prod.env from Terraform outputs through infra/create_env_file.bash, then the Dockerfile adds auto_prepend_file = "/var/www/html/YourBOX/.env.php" to PHP. Secrets end up in image layers.
  • PHP code calls bare getenv('DB_HOST'), getenv('SMTP_USER') and so on, spread across db.php and functions.php. No validation enforces that the required keys exist.
  • Local development reads dev.env via mise. The file format and key names are not documented and the file is recreated by each contributor without a shared template.

The homelab platform fixes the production answer in its README: “Secrets are read from OpenBao through External Secrets Operator. SOPS and Sealed Secrets are reserved for bootstrap and comparison use cases.” YourBOX must align.

YourBOX consumes one env contract regardless of environment. The application reads the following keys at boot through a YourBOX\Bootstrap\Env helper that fails fast when a required key is missing:

  • APP_ENV, APP_DEBUG, APP_SECRET
  • DB_DSN (DSN string carrying scheme, credentials, host, port and database)
  • STORAGE_DSN (DSN string carrying the Flysystem adapter and its credentials)
  • MEILISEARCH_URL, MEILISEARCH_KEY
  • OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET
  • SMTP_DSN, SMTP_FROM

The keys are documented in dev.env at the repository root (committed, placeholder values). The same file is both the development env and the env contract template; no separate .env.example exists. New keys land in dev.env first, then in prod.env.enc once their production value is known.

The backends that populate the env vary by environment:

  • Local dev: the committed dev.env already carries placeholder values that boot the Symfony kernel. A contributor edits any value locally; the workstation copy stays unencrypted because it holds nothing sensitive. The same dev.env feeds mise run image:smoke, which overrides APP_ENV / APP_DEBUG via docker -e flags to exercise the prod kernel path.
  • CI test stage: ephemeral PostgreSQL and MinIO containers; CI mints disposable test credentials inline. No SOPS decryption happens at this stage.
  • CI deploy and VPS: decrypt prod.env.enc using an age private key delivered via OpenBao (CI) or via tmpfs at boot (VPS Ansible). The cleartext prod.env is consumed by the application or written to the appropriate environment variable surface and discarded after deploy.
  • Recipient list in .sops.yaml: one creation_rules entry, path_regex: 'prod\.env$', with the maintainer’s age public key as the single recipient. There is no dev/reader role. The original two-tier model collapsed because no second recipient existed and dev.env.enc had no audience. New admins land through a .sops.yaml PR gated by CODEOWNERS and a mise run secrets-rekey afterwards. A pre-commit hook with gitleaks rejects accidental commits of any cleartext env file.
  • VPS smoke test: the same SOPS encrypted file decrypts on the VPS during the Ansible deployment. The age private key lives on the VPS itself, mounted from a tmpfs.
  • Azure: a GitLab CI job authenticates to OpenBao via JWT, reads kv/yourbox/staging, materializes the env as a short lived dotenv artifact, and the Azure App Service consumes it via its settings API. No file lands in the image.
  • Homelab Kubernetes: the External Secrets Operator reads from OpenBao and writes a Kubernetes Secret. The yourbox Deployment projects the secret as environment variables. Secrets never leave OpenBao through GitLab.

The auto_prepend_file chain and infra/create_env_file.bash are removed. The Symfony kernel calls Env::bootCheck() before handling its first request and the application crashes loudly when a required key is missing.

  • Application code never reads files at runtime for secrets. The Symfony kernel calls Env::require($key) and a single helper handles defaults, type coercion and validation.
  • The container image carries no secret values. Trivy scans and image inspection cannot leak credentials because the layers do not contain them.
  • Onboarding a new developer is git clone then mise install. dev.env ships in the repo so the kernel boots immediately; production access is gated by a .sops.yaml admin-list PR followed by mise run secrets-rekey.
  • Rotating a secret in production is a single OpenBao operation. External Secrets refreshes the Kubernetes Secret automatically, and the next pod restart picks up the new value.
  • Compromising the GitLab runner does not compromise production: the runner has a short lived JWT and only the staging KV path. Production runners are gated by manual approval and a stricter Authentik role.
  • Adding a new secret follows a fixed checklist: update dev.env, add the key to Env::REQUIRED if non optional, regenerate prod.env.enc with the new key, push to OpenBao under the matching KV path, then reference in the Symfony configuration.