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

Container image

The server ships as one container image built from api/Dockerfile. The same image runs everywhere and is configured entirely through environment variables. Each release publishes it to ghcr.io/zerosandonesllc/ridm for linux/amd64 and linux/arm64, signed and with an SBOM attached; see Releases and verification. To build it yourself instead:

Building

docker build -f api/Dockerfile -t ridm .
docker buildx build --platform linux/amd64,linux/arm64 -f api/Dockerfile -t ridm .

Run either from the repository root; the build context is the whole workspace. The build has three stages:

  • ui (node:26.13.0-bookworm-slim, overridable with --build-arg NODE_VERSION=...) runs npm ci and npm run build in ui/ with NEXT_PUBLIC_API_URL empty, producing the same-origin static export ui/out. It runs once on the build platform, since the files are the same for every architecture.
  • builder (rust:1.98.1-bookworm, overridable with --build-arg RUST_VERSION=...) compiles the dependencies first against stub sources, so a source change does not rebuild them, then builds ridm-api in release mode with --locked and --features embedded-ui, which compiles ui/out into the binary, and strips it.
  • runtime (gcr.io/distroless/cc-debian12:nonroot) holds only the binary at /ridm-api. There is no shell and no package manager, and no dynamic OpenSSL: TLS uses rustls with the aws-lc-rs provider.

For organizations that must run FIPS 140-3 validated cryptography there is a second image, built from api/Dockerfile.fips on Red Hat UBI 9 and published as ridm:<version>-fips. It is configured the same way. See FIPS 140-3.

The image serves the sign-in pages and both consoles itself on PUBLIC_URL’s origin (/login/, /console/, /account/, …); nothing else needs hosting. Set EMBEDDED_UI=false for a node that should answer the API alone, or UI_URL to another origin when the pages are hosted elsewhere; see Deployment overview.

Runtime properties

PropertyValue
Entrypoint/ridm-api
Usernonroot:nonroot (distroless’s UID/GID 65532)
Port8080 (EXPOSE 8080, BIND_ADDR=0.0.0.0:8080 set in the image)
SignalsSIGTERM or SIGINT stops accepting connections and drains in-flight requests for up to 20 seconds
ConfigurationEnvironment variables only; a .env file in the working directory is read if present

Because the process runs as non-root it cannot bind ports below 1024 without extra capabilities; keep 8080 and map it, or let a proxy listen on 443.

The minimum environment is DATABASE_URL, REDIS_URL, PUBLIC_URL and the master key (MASTER_KEY, or MASTER_KEY_FILE pointing at a mounted secret file). Every variable is listed in Server configuration. A configuration error prints configuration error: ... and exits with status 2 before anything else happens.

docker run --rm -p 8080:8080 \
  -e DATABASE_URL=postgres://ridm_app:secret@db.internal:5432/ridm \
  -e REDIS_URL=redis://valkey.internal:6379 \
  -e PUBLIC_URL=https://id.example.com \
  -e MASTER_KEY_FILE=/run/secrets/ridm_master_key \
  -e TRUSTED_PROXIES=10.0.0.0/8 \
  -v /srv/ridm/master_key:/run/secrets/ridm_master_key:ro \
  ridm

MASTER_KEY_FILE accepts the raw 32 bytes, or the same hex or base64 text MASTER_KEY takes. When both are set, MASTER_KEY wins.

Commands

The entrypoint is the server; arguments select a one-shot command instead.

ArgumentsWhat it doesExit status
(none)Runs the server1 on a fatal error, 2 on a configuration error
migrateApplies pending migrations and exits0 applied (or nothing to do), 1 failure, 2 configuration error
bootstrap [--email E] [--username U] [--password-stdin] [--no-must-change]Creates the first global administrator in the master tenant (and the sample-spa client when BOOTSTRAP_SAMPLE_CLIENT=true is set with the BOOTSTRAP_ADMIN_* variables). Applies pending migrations only when MIGRATE_ON_START=true; otherwise pending migrations make it stop0 created or already bootstrapped, 1 failure (including pending migrations), 2 bad arguments or configuration
rotate-master-key [--status | --new-generation]Re-encrypts secrets at rest under the current generation; --status only reports; --new-generation first has the key custody backend wrap a new generation0 done, 1 failure
openapiPrints the OpenAPI document of the admin API0
--healthcheckProbes the local server’s /healthz (see below)0 healthy, 1 not

Every command loads the configuration, so migrate and bootstrap also need REDIS_URL and PUBLIC_URL set, even though migrate only talks to Postgres. bootstrap needs the master key (or the key custody settings) too; migrate does not read them.

The image is built with every optional feature: the embedded UI, Kerberos and all the key custody backends. For PKCS#11, mount the HSM vendor’s library and whatever it depends on into the container and point PKCS11_MODULE at it.

bootstrap runs as DATABASE_URL’s role, normally the DML-only app role, so run migrate as the migrator first. With migrations pending and MIGRATE_ON_START off it exits 1 with a message telling you to run ridm-api migrate.

Master key rotation is described in Rotating keys.

Running migrations

Migrations are embedded in the binary (from api/migrations) and applied with sqlx, which takes a Postgres advisory lock, so two runners at once are safe: one waits for the other. There are two ways to apply them.

A separate migrate step (recommended). Run the image with migrate as the role that owns the schema, then start the servers as the DML-only role:

docker run --rm \
  -e DATABASE_URL=postgres://ridm_migrator:secret@db.internal:5432/ridm \
  -e REDIS_URL=redis://valkey.internal:6379 \
  -e PUBLIC_URL=https://id.example.com \
  -e MASTER_KEY_FILE=/run/secrets/ridm_master_key \
  -v /srv/ridm/master_key:/run/secrets/ridm_master_key:ro \
  ridm migrate

This is what the compose file’s migrate service does, and what a Kubernetes Job or a pre-deploy task in your pipeline should do. The API never holds DDL rights, so it cannot disable the row level security that separates tenants.

At startup. MIGRATE_ON_START=true makes each server check for pending migrations before it listens and apply them, if there are any, as its own DATABASE_URL role. Nothing pending means nothing is applied, so a server running as the DML-only app role starts normally with the flag on once ridm-api migrate has run; a pending migration then fails startup, because that role cannot apply it. For the server to migrate itself, its role must own the schema, which gives the running server the power to alter tables and turn row level security off. Simpler, and weaker; keep that for single-node trials.

With MIGRATE_ON_START off (the default) the server does not touch the schema; if migrations are pending it logs a warning (database migrations are pending) and starts.

The two roles and why they differ are covered in Postgres and Valkey. Migrations are forward-only; there are no down migrations, so a rollback means restoring the database from before the upgrade.

Health checks

EndpointMeaningUse as
GET /healthzThe process is up and serving HTTP. Never touches Postgres or Valkey. 200 {"status":"ok","version":"..."}Liveness probe
GET /readyzPostgres and Valkey both answer and no event queue is 80% full. 200 with {"status":"ok","checks":{"database":"ok","cache":"ok","events":"ok"}}, or 503 with "status":"degraded" and the failing check as "fail" ("saturated" for events)Readiness probe, load-balancer health check

Use /healthz for liveness so a database outage does not make the orchestrator restart every node, and /readyz for readiness so traffic stops reaching a node that cannot serve it. More in Observability.

The image has no curl, so the binary probes itself: /ridm-api --healthcheck requests /healthz at the address in BIND_ADDR, with a 2-second timeout. A wildcard bind address (0.0.0.0 or ::) becomes the loopback address of the same family; any other address is dialled as it is. When TLS_CERT is set the probe speaks https and trusts exactly that certificate: the server must present it and prove it holds its key. The certificate’s names, chain and dates are not checked, because the probe dials an IP address and only asks whether the process answers.

The Dockerfile declares the probe:

HEALTHCHECK --interval=15s --timeout=5s --start-period=60s --retries=3 \
    CMD ["/ridm-api", "--healthcheck"]

The compose file overrides the timing for local use:

healthcheck:
  test: ["CMD", "/ridm-api", "--healthcheck"]
  interval: 10s
  timeout: 3s
  retries: 10
  start_period: 15s

Kubernetes ignores Docker’s HEALTHCHECK; use HTTP probes on /healthz and /readyz there, or an exec probe running /ridm-api --healthcheck.

The port opens only after startup work finishes: the server connects to Postgres, applies pending migrations if MIGRATE_ON_START is on, pings Valkey, runs bootstrap if configured, and makes sure every tenant has its console clients. Give the probes a start period (the image allows 60 seconds, the compose file 15 seconds and then ten retries) rather than expecting the port at once.

Kubernetes

Use the Helm chart; see Kubernetes (Helm). It is the same pieces a hand-written manifest would need: a Deployment of the image with the probes above, a Job running migrate as the migrator role before each rollout, the master key and database passwords from Secrets (the key mounted as a file for MASTER_KEY_FILE, out of the process environment), and a Service behind your Ingress with TRUSTED_PROXIES set to the Ingress controller’s pod network, with terminationGracePeriodSeconds above the 20-second drain.