Obexal Docs

Docs/Self-hosting/Deploy with Docker Compose

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)ServicePurpose
api.example.comauth-serviceThe API and OIDC issuer: this is the value of OIDC_ISSUER
admin.example.comadminThe administration console
accounts.example.comlogin-uiThe default sign-in UI
example.com (optional)static filesThe 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.com
Warning

Set 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 --build

The one-shot migrate service applies the SQL migrations first; the auth-service only starts once migrations have completed and PostgreSQL and Redis report healthy.

Note

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 Redis

The 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-ui

Then check /readyz, the logs, and one end-to-end login on a test organization.