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) andPOSTGRES_PASSWORD. -
APP_ENV=productionset.PUBLIC_ORIGINoptional (only to pin an origin). - Proxy forwards
Host(andX-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
migrateservice applies schema changes on everyup; run it before app rollout in a controlled deploy.
Secrets
- Keep
.envout of version control; inject secrets via your orchestrator if you have one. - Rotate
BETTER_AUTH_SECRETdeliberately — 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
appandworkerare 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.