Skip to content

YourBOX Secret Management

ADR-0008 commits YourBOX to a single env contract regardless of environment. This page documents the keys, how each environment populates them, and how the application boot sequence validates them.

YourBOX requires the following environment variables at boot. Missing a required key crashes the Symfony kernel loudly.

Key Required Carried by Example
APP_ENV yes All environments dev, vps, staging, prod
APP_DEBUG yes All environments 1 only when APP_ENV=dev
APP_SECRET yes All environments 32+ random bytes hex
DB_DSN yes All environments postgresql://user:pass@host:5432/yourbox
STORAGE_DSN yes All environments s3://key:secret@minio.local/yourbox?region=...
MEILISEARCH_URL yes All environments https://meili.yourbox.site
MEILISEARCH_KEY yes All environments API key
OIDC_ISSUER prod, staging Authentik tenant URL https://auth.yourbox.site/application/o/yourbox/
OIDC_CLIENT_ID prod, staging Authentik client yourbox
OIDC_CLIENT_SECRET prod, staging Authentik client secret
SMTP_DSN yes All environments smtps://user:pass@smtp.example:465
SMTP_FROM yes All environments admin@yourbox.site

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 Env::REQUIRED if non optional, then in the OpenBao KV path.

The Env helper (YourBOX\Bootstrap\Env) exposes a small typed API and a boot check:

namespace YourBOX\Bootstrap;
final class Env
{
// Keys required at boot by plan 002. Additional keys (STORAGE_DSN,
// MEILISEARCH_*, OIDC_*) are added as later plans deliver those features.
public const REQUIRED = [
'APP_ENV',
'APP_DEBUG',
'APP_SECRET',
'DB_DSN',
'SMTP_DSN',
'SMTP_FROM',
];
public static function require(string $key): string;
public static function get(string $key, string $default = ''): string;
public static function bool(string $key, bool $default = false): bool;
public static function dbDsn(): array;
public static function smtpDsn(): array;
public static function bootCheck(): void;
}

The application calls Env::bootCheck() at startup. It crashes before handling its first request when a required key is missing. The invocation site moves to the Symfony kernel in plan 003.

The .sops.yaml file at the repository root carries one creation_rules entry with a single recipient: the maintainer’s age public key.

creation_rules:
- path_regex: 'prod\.env$'
age:
# admins (full rights, can rotate production secrets and add recipients)
- age1...

There is no dev / reader role. The original two-tier model was collapsed because no second recipient existed and dev.env.enc had no audience. The boundary between local and production is the file separation: dev.env is committed plaintext (placeholders), prod.env.enc is encrypted and the plaintext prod.env is gitignored.

CODEOWNERS on .sops.yaml gates admin-list changes; existing admins approve new admin keys.

First time on the repository (dev onboarding)

Section titled “First time on the repository (dev onboarding)”

A new contributor runs:

Terminal window
git clone <repo>
cd yourbox
mise trust && mise install

The committed dev.env already loads through mise’s _.file = "dev.env" and the Symfony kernel boots. Editing dev.env is allowed. Values stay on the workstation. No age key is required to start writing code.

Joining the admin list (production access)

Section titled “Joining the admin list (production access)”

When a contributor needs to access production secrets, they run:

Terminal window
mise run age-bootstrap

The script generates an age key under ~/.config/sops/age/keys.txt if absent, then appends the public key to .sops.yaml. The resulting PR requires existing-admin approval. After merge, an existing admin runs:

Terminal window
mise run secrets-rekey
git add . && git commit -m 'chore(sops): rekey for updated admin list'

The rekey re-encrypts prod.env.enc with the new recipient list. The new admin pulls and can decrypt prod.env.enc via SOPS.

dev.env ships with the repo, so the post install hook does no decryption. Production secrets are never auto-decrypted on a workstation; admins explicitly run sops --decrypt prod.env.enc > prod.env when needed and delete the cleartext file when done. A pre commit hook with gitleaks rejects accidental commits of credentials. The pre commit configuration is shared with every other app in the workspace.

CI test stage runs against ephemeral PostgreSQL and MinIO containers and mints disposable test credentials inline. CI deploy stages decrypt prod.env.enc using an age private key delivered via OpenBao.

The Ansible playbook for YourBOX on the VPS copies the same prod.env.enc from the repository, decrypts it on the VPS using an age private key stored on a tmpfs at boot, then keeps the cleartext file mode at 0400 owned by the application user. The compose stack reads it through env_file: prod.env.

The age private key on the VPS never lands on disk. It is loaded into tmpfs at first boot from a Cloudflare R2 path protected by a short lived presigned URL, the key file is read into the SOPS keyring environment and the tmpfs mount discards on every reboot.

Terraform builds DB_DSN and SMTP_DSN from individual Terraform variables using format() and urlencode(), then injects the full env contract into the App Service via the azurerm_linux_web_app.app_settings block. The Azure App Service surfaces these settings as environment variables in the container; the application reads them through YourBOX\Bootstrap\Env. No secrets file lands on disk or in the container image.

app_settings = {
"APP_ENV" = "prod"
"APP_DEBUG" = "0"
"APP_SECRET" = var.APP_SECRET
"DB_DSN" = local.db_dsn # mysql://user:urlencode(pass)@fqdn:3306/db
"SMTP_DSN" = local.smtp_dsn # smtps://user:urlencode(pass)@host:465
"SMTP_FROM" = var.SMTP_FROM
}

The Terraform variables (APP_SECRET, DB_SQL_PASSWORD, SMTP_PASSWORD, etc.) are supplied by GitLab CI variables scoped to the production environment. Rotating a secret is a CI variable update followed by a terraform apply.

OpenBao replacing the Terraform-variable source is future work. When that swap happens the application contract (DB_DSN, SMTP_DSN, etc.) stays identical; only the Terraform locals that build the DSN strings change.

The External Secrets Operator reads from OpenBao and writes a Kubernetes Secret. The YourBOX Deployment projects the secret as environment variables. The mapping lives in the Helm chart under k8s/charts/yourbox/templates/externalsecret.yaml:

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: yourbox-env
namespace: yourbox
spec:
refreshInterval: 5m
secretStoreRef:
name: openbao-prod
kind: ClusterSecretStore
target:
name: yourbox-env
template:
type: Opaque
dataFrom:
- extract:
key: yourbox/prod

The pod template references the secret through envFrom:

envFrom:
- secretRef:
name: yourbox-env

Rotating a secret in production is a single OpenBao write. The External Secrets controller refreshes the Kubernetes Secret within five minutes, and a rolling restart of the YourBOX Deployment picks up the new value.

The checklist below applies whenever the application reads a new variable.

  1. Add the variable to dev.env with a placeholder value and a comment.
  2. If the variable is required at boot, add it to Env::REQUIRED.
  3. Wire the variable in app/config/services.yaml or in the relevant Symfony configuration file.
  4. Write the value to OpenBao under kv/yourbox/staging and kv/yourbox/prod.
  5. Update prod.env.enc with the new key via SOPS edit. Run mise run secrets-rekey if the age recipient list also changed.
  6. Update this page with a row in the env contract table.
  • The application never reads files at runtime to obtain secrets. Files are an injection vehicle, not a storage location.
  • The container image carries no secret values. Trivy scans and image inspection cannot leak credentials.
  • Authentik client secrets, MinIO root credentials, OpenBao unseal keys and any other platform secret live in OpenBao only. The application path (kv/yourbox/*) is read only and scoped to YourBOX.