Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Production with docker-compose

deploy/production/ runs rIDM on one host for real use: the server behind a TLS-terminating reverse proxy, Postgres and Valkey on a network with no route out and no published port, and every secret in a file rather than in the environment. It is a separate file from deploy/docker-compose.yml, which is for evaluation and development.

Every CI run boots this stack behind each of the three proxies and checks it from the outside (see What CI checks).

The stack

ServiceImageNetworksWhat it does
postgrespostgres:18.6-alpinebackendThe database. First initialisation creates the migrator and app roles (deploy/postgres/init-app-role.sh) with passwords from secret files
valkeyvalkey/valkey:9.1.2-alpine3.24backendValkey with a password, append-only persistence and noeviction
migratethe rIDM imagebackendOne-shot: ridm-api migrate as the schema owner, then exits
ridmthe rIDM imageedge, backendThe server, with the embedded UI. Not published on the host
caddy, nginx or traefiksee belowedgeThe proxy, on ports 80 and 443. One of the three, by profile

backend is an internal network: nothing on it can reach the internet, and nothing on the host can reach Postgres or Valkey. edge carries the proxy and the server, and the server’s outbound traffic (SMTP, webhooks, upstream identity providers). Its subnet is fixed (RIDM_EDGE_SUBNET, default 172.30.53.0/24) so the proxy’s address (RIDM_PROXY_ADDRESS, default 172.30.53.10) is known in advance, and that one address is the server’s TRUSTED_PROXIES. Trusting the whole subnet would be wrong: with Docker’s userland proxy, a connection from the host arrives at the proxy from the subnet’s gateway, and behind a proxy that appends to X-Forwarded-For (nginx) rIDM would skip that trusted address and record whatever the caller had written further left. The smoke test below catches exactly that.

Hardening applied to every service: no-new-privileges, JSON-file logs capped at five files of 20 MB, restart: unless-stopped. The rIDM containers also run with a read-only root filesystem, every capability dropped, a 16 MB /tmp and a memory limit (RIDM_MEMORY_LIMIT, default 1g); Caddy and Traefik keep only NET_BIND_SERVICE.

Setting it up

cd deploy/production
cp .env.example .env     # edit: RIDM_DOMAIN, COMPOSE_PROFILES, RIDM_TLS_MODE, ...
./init.sh                # writes ./secrets once
docker compose up -d

init.sh writes random passwords for the Postgres superuser, the migrator and app roles and Valkey; the connection URLs built from them; the master key; a metrics token; an empty smtp_password; and, when BOOTSTRAP_ADMIN_EMAIL is set, a one-time password for the first administrator. It never overwrites a file that has content, so running it again is safe and rotates nothing. secrets/ is 0700; the files in it are 0644, because compose bind-mounts them unchanged and the containers read them as their own non-root users. secrets/, certs/ and .env are git-ignored.

Copy secrets/master_key off the host. It encrypts signing keys, MFA secrets and provider credentials in the database; a backup restored without it is unreadable. See Signing keys and the master key.

Releases publish the image (Releases and verification); until the first one is cut, build it from the checkout, docker compose build, or set RIDM_IMAGE to one you built and pushed. Pin a version, or better a digest, rather than latest.

The first administrator

With BOOTSTRAP_ADMIN_EMAIL set before init.sh runs, the first start creates the global administrator in master (username admin) with the password in secrets/bootstrap_admin_password, marked for change at first sign-in. Sign in at https://{RIDM_DOMAIN}/console/. Bootstrap does nothing once an owner exists, so the setting can stay. To retire it, empty BOOTSTRAP_ADMIN_EMAIL and the file together: the server refuses to start with only one of the two set.

Without it, create one with the server’s bootstrap command:

docker compose run --rm -T ridm bootstrap --email admin@example.com --password-stdin

Mail

Set SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_FROM and SMTP_SECURITY in .env and put the password in secrets/smtp_password. This is the fallback for tenants without SMTP settings of their own; see Messaging.

Choosing a proxy

COMPOSE_PROFILES in .env picks one: caddy, nginx or traefik. All three are configured alike: one site for RIDM_DOMAIN and every name in RIDM_CUSTOM_DOMAINS (space-separated), everything proxied to rIDM with Host passed through, the client’s address in X-Forwarded-For, no request-body limit below the 32 MiB bulk import takes, /metrics refused, and no headers of their own: rIDM sends its security headers and HSTS. What differs is certificates.

ProxyImageCertificatesConfiguration
Caddycaddy:2.11.4-alpineACME (RIDM_TLS_MODE=acme, default) or filesdeploy/proxy/caddy/Caddyfile
nginxnginx:1.30.5-alpinefiles onlydeploy/proxy/nginx/templates/default.conf.template
Traefiktraefik:v3.7.13ACME (default) or filesthe traefik service’s command: and deploy/proxy/traefik/dynamic/ridm.yml

ACME (Caddy, Traefik): the proxy obtains and renews Let’s Encrypt certificates for every configured host through the HTTP challenge, so ports 80 and 443 must be reachable from the internet and each host’s DNS must point at this machine first. Certificates live in the caddydata or traefikacme volume.

Files (RIDM_TLS_MODE=files, and always for nginx): put fullchain.pem and privkey.pem in deploy/production/certs/, one certificate naming every host. Renewing them is your ACME client’s job; afterwards reload the proxy (docker compose exec nginx nginx -s reload, or restart Caddy or Traefik).

Plain http is redirected to https by all three. A host the proxy does not serve is refused (nginx rejects the TLS handshake and answers 444 on port 80; Caddy has no certificate for it; Traefik answers 404).

The same files work outside compose with small changes, described in each file’s header and in TLS and reverse proxies.

Custom domains

A tenant’s custom domain is set on the tenant as usual; this stack also needs the domain in RIDM_CUSTOM_DOMAINS, a DNS record pointing at the host, and (in files mode) the name in the certificate. Then docker compose up -d recreates the proxy with the new site, and an ACME proxy obtains its certificate as it starts.

Variables

Read from .env next to compose.yml (.env.example has all of them):

VariableDefaultUsed for
COMPOSE_PROFILESnoneThe proxy: caddy, nginx or traefik
RIDM_DOMAINrequiredThe public host; PUBLIC_URL is https://{RIDM_DOMAIN}
RIDM_CUSTOM_DOMAINSemptyTenants’ custom domains, space-separated
RIDM_TLS_MODEacmeacme or files (nginx always uses files)
BOOTSTRAP_ADMIN_EMAILemptyFirst administrator, created on first start
SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_FROM, SMTP_SECURITYempty, 587, empty, empty, starttlsServer-wide SMTP fallback
RIDM_IMAGEghcr.io/zerosandonesllc/ridm:latestThe rIDM image
RIDM_MEMORY_LIMIT1gMemory limit of the server container
RIDM_PG_SHARED_BUFFERS512MBPostgres shared_buffers
RIDM_VALKEY_MAXMEMORY512mbValkey maxmemory; with noeviction, writes fail when it is reached, so size it with headroom
RIDM_HTTP_PORT, RIDM_HTTPS_PORT80, 443Host ports of the proxy
RIDM_EDGE_SUBNET, RIDM_PROXY_ADDRESS172.30.53.0/24, 172.30.53.10The proxy network and the proxy’s address in it; change both together, and only on a clash
RIDM_SECRETS_DIR, RIDM_CERTS_DIR./secrets, ./certsWhere the secret files and certificates are
RUST_LOGinfoLog filter

Other server settings are not passed through. Add them to the ridm service’s environment in a compose.override.yml; the full list is in Server configuration.

Operating it

  • Metrics. /metrics is refused at the proxy and needs the token in secrets/metrics_token. Scrape it from a Prometheus attached to the edge network: http://ridm:8080/metrics with that bearer token.
  • Logs. JSON lines on the server’s standard output: docker compose logs ridm.
  • Upgrading. Change RIDM_IMAGE and docker compose up -d. migrate runs first and the server starts only if it succeeds. Back up the database before upgrading; see Upgrading.
  • Backups. docker compose exec -T postgres pg_dump -U ridm -Fc ridm > ridm.dump (as the superuser ridm, which row level security does not stop). Keep a copy of secrets/master_key somewhere else: never beside the dumps. See Backup and restore.
  • Security contact. Set SECURITY_CONTACT in .env so that /.well-known/security.txt tells researchers where to report a problem.
  • More than one host. This stack is one node. For several, run the server on each host against a shared Postgres and Valkey, or use the Helm chart.

What CI checks

deploy/production/smoke/run.sh, in the packaging job, generates a private CA and a certificate, runs init.sh, starts the stack in files mode with the image CI built, and gives the master tenant a custom domain. Then, behind each proxy in turn, it checks over https that plain http is redirected; discovery names the https issuer and JWKS answers; /console/ and /login/ arrive with rIDM’s framing headers and exactly one HSTS header; /metrics is refused; a forged X-Forwarded-For is not the address rIDM records, and neither is the proxy’s own (read from rIDM’s per-address rate-limit counters in Valkey); a 20 MiB bulk-import body reaches rIDM whole (an administrator’s token makes rIDM read it all before refusing it as JSON, so a proxy limit or a cut-off upload fails the check); the custom domain serves the tenant with its own issuer; and an unknown host gets nothing from rIDM. Once per run it also checks that Postgres and Valkey publish no port, that no secret’s value appears in docker inspect, and that the server is not given the migrator’s database URL.

docker build -f api/Dockerfile -t ridm:smoke .
RIDM_IMAGE=ridm:smoke deploy/production/smoke/run.sh            # all three
RIDM_IMAGE=ridm:smoke deploy/production/smoke/run.sh nginx      # one

It binds ports 80 and 443 on the machine running it.