ADR-0008: Manage YourBOX Secrets With SOPS And age In Dev, OpenBao And External Secrets In Production
Status
Section titled “Status”Accepted
Context
Section titled “Context”YourBOX today receives secrets through three incompatible paths:
- The CI pipeline builds an image baked
prod.envfrom Terraform outputs throughinfra/create_env_file.bash, then the Dockerfile addsauto_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 acrossdb.phpandfunctions.php. No validation enforces that the required keys exist. - Local development reads
dev.envvia 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.
Decision
Section titled “Decision”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_SECRETDB_DSN(DSN string carrying scheme, credentials, host, port and database)STORAGE_DSN(DSN string carrying the Flysystem adapter and its credentials)MEILISEARCH_URL,MEILISEARCH_KEYOIDC_ISSUER,OIDC_CLIENT_ID,OIDC_CLIENT_SECRETSMTP_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.envalready 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 samedev.envfeedsmise run image:smoke, which overridesAPP_ENV/APP_DEBUGvia docker-eflags 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.encusing an age private key delivered via OpenBao (CI) or via tmpfs at boot (VPS Ansible). The cleartextprod.envis consumed by the application or written to the appropriate environment variable surface and discarded after deploy. - Recipient list in
.sops.yaml: onecreation_rulesentry,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 anddev.env.enchad no audience. New admins land through a.sops.yamlPR gated by CODEOWNERS and amise run secrets-rekeyafterwards. 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.
Consequences
Section titled “Consequences”- 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 clonethenmise install.dev.envships in the repo so the kernel boots immediately; production access is gated by a.sops.yamladmin-list PR followed bymise 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 toEnv::REQUIREDif non optional, regenerateprod.env.encwith the new key, push to OpenBao under the matching KV path, then reference in the Symfony configuration.