Deploy with Docker Compose
Run the production stack with compose.prod.yml and the Caddy TLS overlay: DNS, environment file, first launch, health checks and updates.
The reference deployment is a single host running Docker Compose: compose.prod.yml describes the application stack, and the compose.tls.yml overlay puts Caddy in front of it to terminate HTTPS with automatic ACME certificates.
Before you begin
Check the requirements first. You need the Obexal source tree on the host (the images are built locally from it), a domain you control, and an EU SMTP relay. Every variable mentioned below is detailed in the configuration reference.
1. Plan the DNS hosts
The stack serves three hosts, plus an optional apex. All records point to the public IP of the host.
| Host (example) | Service | Purpose |
|---|---|---|
api.example.com | auth-service | The API and OIDC issuer: this is the value of OIDC_ISSUER |
admin.example.com | admin | The administration console |
accounts.example.com | login-ui | The default sign-in UI |
example.com (optional) | static files | The apex: adapt or remove its block in deploy/caddy/Caddyfile |
Any other HTTPS hostname reaching Caddy is treated as a tenant custom domain: a certificate is only issued on demand if the auth-service confirms the domain is verified (GET /v1/tls/authorize). See Custom domains.
2. Configure the environment
Create a .env.prod file next to the Compose files. Never commit it. A minimal production file:
APP_ENV=production
OIDC_ISSUER=https://api.example.com
SESSION_SECURE=true
# Encryption at rest (base64, 32 bytes): generate with `openssl rand -base64 32`
ENCRYPTION_KEY=$GENERATED_ENCRYPTION_KEY
# Local containers (prefer managed EU services in production)
POSTGRES_USER=obexal
POSTGRES_PASSWORD=$GENERATED_DB_PASSWORD
POSTGRES_DB=obexal
# Object storage credentials (MinIO, tenant logos)
S3_ACCESS_KEY=$GENERATED_S3_ACCESS_KEY
S3_SECRET_KEY=$GENERATED_S3_SECRET_KEY
# Outbound email (EU relay; the "log" transport is refused in production)
MAIL_TRANSPORT=smtp
SMTP_HOST=smtp.example.eu
SMTP_PORT=587
SMTP_USERNAME=$SMTP_USERNAME
SMTP_PASSWORD=$SMTP_PASSWORD
MAIL_FROM=no-reply@example.com
# Caddy (TLS overlay)
OBEXAL_ACME_EMAIL=ops@example.com
OBEXAL_API_HOST=api.example.com
OBEXAL_ADMIN_HOST=admin.example.com
OBEXAL_LOGIN_HOST=accounts.example.com
# Baked into the UI builds
NEXT_PUBLIC_OBEXAL_API_URL=https://api.example.com
NEXT_PUBLIC_LOGIN_UI_URL=https://accounts.example.comSet TRUSTED_PROXIES to the network range of your reverse proxy. Without it, every client appears to come from the proxy's IP and per-IP rate limiting is inoperative.
3. Launch the stack
One command builds the three application images from source and starts everything:
docker compose -f compose.prod.yml -f compose.tls.yml --env-file .env.prod up -d --buildThe one-shot migrate service applies the SQL migrations first; the auth-service only starts once migrations have completed and PostgreSQL and Redis report healthy.
Once Caddy is in place, the application services should not publish ports on the host: remove their ports: entries in compose.prod.yml (or turn them into expose:). Only Caddy needs 80 and 443.
4. Verify the deployment
docker compose -f compose.prod.yml -f compose.tls.yml --env-file .env.prod ps
curl -fsS https://api.example.com/healthz # liveness, no dependency
curl -fsS https://api.example.com/readyz # readiness: PostgreSQL and RedisThe API also exposes GET /metrics in Prometheus format (request counts, latency histogram, in-flight requests). Protect it with METRICS_TOKEN or keep it on the internal network only. Each container additionally has a Docker healthcheck: the distroless auth-service probes itself through a built-in healthcheck subcommand, since the image contains no shell.
Finish with a real end-to-end login on a test organization.
Migrations are forward-only
Migrations are embedded in the auth-service binary and applied in lexical order, each in its own transaction, recorded in schema_migrations. Applying them is idempotent. There are no down migrations: this is a deliberate choice for an identity system, where generic rollback migrations destroy data. Rolling back means redeploying the previous image, and restoring a backup only if a migration introduced an incompatible change. See Backups and restore.
Hardened images
- auth-service: a static Go binary in a distroless image, non-root, no shell, no package manager.
- admin: Next.js standalone output on node:20-slim, non-root.
- login-ui: a static export served by nginx-unprivileged.
Base images are pinned to precise minor versions. For production, pin them by digest (@sha256:) through an EU registry mirror.
Update to a new version
Always back up before updating: migrations are forward-only.
./scripts/backup.sh
git pull
docker compose -f compose.prod.yml -f compose.tls.yml --env-file .env.prod build
docker compose -f compose.prod.yml -f compose.tls.yml --env-file .env.prod run --rm migrate
docker compose -f compose.prod.yml -f compose.tls.yml --env-file .env.prod up -d auth-service admin login-uiThen check /readyz, the logs, and one end-to-end login on a test organization.