Skip to main content

Production hardening

The app is production-ready as a container but ships with development defaults. Work through this checklist before exposing it.

Checklist​

  • Real BETTER_AUTH_SECRET (32+ chars) and POSTGRES_PASSWORD.
  • APP_ENV=production set. PUBLIC_ORIGIN optional (only to pin an origin).
  • Proxy forwards Host (and X-Forwarded-Proto); the app derives the origin from them.
  • TLS terminated by a reverse proxy; app port not published publicly.
  • OAUTH_* pointed at Authentik; role map verified with a real login.
  • Database on a persistent volume or managed Postgres; backups scheduled.
  • Logs shipped/retained; healthcheck monitored.

Origin and TLS​

Put a TLS-terminating reverse proxy in front of the app. The origin is derived per request from the Host header, so no rebuild is needed when the public URL changes. The proxy must forward the original Host, and the app trusts X-Forwarded-Proto/X-Forwarded-Host via PROTOCOL_HEADER/HOST_HEADER.

To pin a canonical origin instead, set ORIGIN (compose: PUBLIC_ORIGIN) and recreate the app — no rebuild:

# .env
APP_ENV=production
# optional; omit to derive the origin from the request Host
PUBLIC_ORIGIN=https://intranet.example
docker compose up -d --force-recreate app worker

The proxy forwards to app:3000. Example nginx server block:

server {
listen 443 ssl;
server_name intranet.example;
ssl_certificate /etc/ssl/intranet.example.crt;
ssl_certificate_key /etc/ssl/intranet.example.key;

client_max_body_size 10m;

location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

The app trusts X-Forwarded-Proto/X-Forwarded-Host (via PROTOCOL_HEADER/HOST_HEADER, defaults shown in the image/compose) to compute the origin when ORIGIN is unset. If you set ORIGIN, keep it equal to the browser URL.

Database​

For anything beyond evaluation, run Postgres outside compose (managed service or a dedicated host) and point DATABASE_URL at it. Remove or ignore the bundled db service. Use TLS and a least-privilege role.

  • Money is stored as numeric; don't coerce through floats.
  • Enable automated backups and test a restore.
  • The migrate service applies schema changes on every up; run it before app rollout in a controlled deploy.

Secrets​

  • Keep .env out of version control; inject secrets via your orchestrator if you have one.
  • Rotate BETTER_AUTH_SECRET deliberately — it signs sessions, so a change logs everyone out.
  • API tokens and service-client secrets are hashed at rest; the plaintext is shown once at creation.

Staging isolation​

Run a staging stack with APP_ENV=staging so it refuses to touch production endpoints:

APP_ENV=staging
STAGING_ALLOWED_EMAILS="alice@example.com,bob@example.com"
PRODUCTION_DATABASE_URL="postgres://…/intranet"
PRODUCTION_OAUTH_ISSUER="https://authentik.example/application/o/cloud-exit-intranet"

A staging stack with these set will reject a production DATABASE_URL or issuer and only admit allowlisted identities.

Resource notes​

  • app and worker are separate processes; scale them independently.
  • The worker is safe to run as a single replica; the outbox guarantees at-least-once dispatch.
  • Set container memory/CPU limits appropriate to your traffic; the app is stateless apart from the database.