Skip to content

Deployment

What has to run

Component Why
provider The application. One process, one port, all modules.
PostgreSQL 18 With the pgvector extension, for the biometrics that come later.
Redis 8 Sessions, pending sign-ins, the denylist, rate limits.
An S3-compatible store Profile photos, and the biometric module's images later. Optional: leave IDEN_S3_ENDPOINT_URL empty and photos are unavailable, nothing else changes.
A reverse proxy Puts all three applications on one origin, limits floods, and tells the provider who the caller is.

provider/Dockerfile and deploy/docker-compose.yml build and wire the first four; docker compose -f deploy/docker-compose.yml up --build is a working development deployment, with every port published on loopback.

The proxy is deploy/docker-compose.tunnel.yml, an overlay adding nginx and cloudflared:

docker compose -f deploy/docker-compose.yml \
               -f deploy/docker-compose.tunnel.yml up -d --build

TLS is not the proxy's job in that arrangement — Cloudflare terminates it at the edge, and the tunnel means no inbound port is open at all. Behind a Cloudflare Tunnel is the full walkthrough. Terminating TLS on your own proxy instead is fine; deploy/nginx/iden.conf.example is then the routing to copy, with a listen 443 ssl block and certificates added.

The object store there is SeaweedFS, chosen for its licence rather than its features — the provider speaks the S3 API and nothing else, so MinIO, Garage, or AWS S3 need only different values for IDEN_S3_ENDPOINT_URL and the two credentials.

The provider is a single deployable. The admin, entity, and biometric modules are logical boundaries, not separate services — one image, one port, one thing to run.

Order of operations

# 1. Schema. Safe to run repeatedly; does nothing when already current.
uv run alembic upgrade head

# 2. Catalogue and bootstrap. Idempotent — creates nothing structural.
uv run python -m scripts.seed

# 3. The application.
uv run provider

The seed refuses to run against an unmigrated database rather than half-filling it. That refusal is the useful behaviour: it means a deployment cannot silently come up with a schema that does not match the code.

Upgrading

git pull
docker compose -f deploy/docker-compose.yml up -d --build

The migrate service runs alembic upgrade head to completion before the provider starts, so the schema is handled. Add the tunnel overlay's -f if you deploy with it. Then, only if the release shipped new permissions:

docker compose -f deploy/docker-compose.yml exec provider python -m scripts.seed
uv run alembic upgrade head
uv run python -m scripts.seed     # only if the scope catalogue changed

Re-run the seed whenever the scope catalogue has changed: it adds the new scopes to the administrator role, and skipping it leaves administrators unable to reach the endpoints that require them.

Read migrations before applying them

Autogenerated migrations are reliable for added tables and columns and unreliable for anything inferred. A renamed column looks like a drop plus an add, and the data in it disappears silently. See Database migrations.

Behind a proxy

Four things IDEN needs from whatever sits in front of it. The third is the one that is usually missed, and the one that fails as an outage rather than an error.

Behind a Cloudflare Tunnel is this arrangement written out end to end, with a tested nginx configuration in deploy/nginx/iden.conf.example.

TLS

Set IDEN_ENV=prod so the session cookie is marked Secure and never travels in the clear. It also withholds /docs, /redoc and /openapi.json, which otherwise publish the complete shape of your admin API to anyone who asks.

If TLS is terminated at an edge — a CDN, a tunnel — make sure plain HTTP is redirected there rather than at your own proxy. A Secure cookie set over an http:// visit is accepted by the browser and then never sent back, which presents as a sign-in loop with no error anywhere.

One origin, and the paths on it

All three applications belong on one hostname, so that the session cookie is unambiguously first-party:

Path Serves
/.well-known/*, /oauth2/*, /api/v1/auth/*, /admin/*, /entity/*, /media/* provider
/auth/* auth-ui
/console/* dashboard

Two of these are not free choices. /.well-known/* must be at the host rootIDEN_API_PREFIX exists for unusual cases and must otherwise stay empty. And the dashboard cannot be at the root, because the provider's API already owns /admin/* and the dashboard's own admin screens have the same names; on one origin /admin/users has to be either the API or the page.

The frontends are built with a Vite base matching their path, so their assets live under /auth/assets/ and /console/assets/. Serving them from different paths means rebuilding them.

The caller's address

IDEN attributes a request to an address in two places: the per-address rate limits, and the ip column of every audit row. Both read the same value, and behind a proxy that value is the proxy — unless you say otherwise.

IDEN_FORWARDED_ALLOW_IPS names the addresses whose X-Forwarded-For is believed. It is empty by default, which trusts nobody and is right when nothing sits in front.

Set it, and set it narrowly

Left empty behind a proxy, every request appears to come from one address and the per-address limits become deployment-wide ones. TOKEN_PER_IP stops being 120 requests a minute per caller and becomes 120 a minute in total — an outage at a few hundred active sessions. Every audit row records the proxy, so the log can no longer say where anyone signed in from.

Set to *, a header anyone can set decides who they are counted as, and every per-address limit and every audited address becomes forgeable. Never *. Name the proxy's network.

The proxy must send a matching header. Have it derive the value itself and send one address rather than appending to a chain — a chain is only read correctly if the trusted list names every hop that appended to it, and a missed hop silently yields that proxy's address instead of the caller's.

Flood protection

IDEN's own limits are per account and per address, and they run inside Python. A coarse limit belongs in the proxy, where a flood is refused before Python is involved at all.

Whatever limiter you use, it keys on an address too — so it needs the same correction. nginx's limit_req_zone $binary_remote_addr behind an uncorrected proxy rate-limits the proxy as a single client, which is to say the entire internet as one bucket.

Two details worth setting: answer 429 rather than nginx's default 503, which reads as an outage and carries no Retry-After; and cover /oauth2/token as well as /api/v1/auth/*, because token exchange verifies a client secret with argon2 in the same way the password routes do.

Signing keys

uv run python -m scripts.gen_keys

These sign every token. Anyone who can read them can mint a token for anyone.

  • Keep them out of the image and mount them read-only.
  • Back them up somewhere you would be comfortable keeping a password.
  • Losing them invalidates every token in circulation — recoverable, but everyone signs in again.

Rotation is filename-ordered; see Configuration.

Rotation needs a rolling restart, and five minutes of patience

The keys are read once at startup, so a new file is invisible until the process restarts. Plan for two waits, not one: restart the replicas, then leave the old key in place for at least five minutes more. Resource servers cache the key set for that long, and one still holding the previous copy will reject a token signed with the new key it has never seen.

Backups

What Policy
PostgreSQL The real data. Back it up.
Signing keys Back them up separately, with different access.
Redis Does not need backing up. Losing it signs everyone out and loses nothing permanent.

Backup and restore has the commands, the order to restore in, and how to test a backup without touching the deployment you are running.

Health

Three endpoints, because "is it healthy" is really three questions with different consequences.

Endpoint Answers What to do when it fails
GET /health/live Is the process running? Touches nothing else. Restart it.
GET /health/ready Can it serve a request end to end? 503 when PostgreSQL or Redis is unreachable. Stop sending it traffic. Do not restart.
GET /health The same detail, always 200. Read it.

The split matters more than it looks. Point a liveness probe at the database and the first bad minute PostgreSQL has restarts every replica you own — turning an outage that would have recovered into a restart loop that will not. Liveness asks is this process wedged; readiness asks is this replica useful right now.

GET /health stays always-200 for a human: a monitor needs to tell the service is down from the service is up and telling you a dependency is down, and a 5xx conflates them.

{"status": "degraded", "database": "ok", "redis": "unreachable"}

Housekeeping

Authorization codes and refresh tokens are not deleted when they expire. Nothing breaks — expired credentials are refused either way — but both tables grow for the life of the deployment. Schedule this:

uv run python -m scripts.cleanup --dry-run   # count what would go
uv run python -m scripts.cleanup             # delete it

Cron, a Kubernetes CronJob, or a systemd timer — daily is plenty. It is not an in-process background task on purpose: every replica would race on the same rows, and a job you cannot run by hand is a job you cannot debug.

It deletes only rows past expiresAt plus an hour's margin. A revoked token that has not yet expired is kept deliberately — reuse detection works by finding the spent row and revoking its family, so removing it early would turn a detectable theft into an ordinary invalid_grant.

audit_events is never touched. How long to keep a record of who did what is your organization's decision, not a maintenance script's.

Response headers

The provider sets Content-Security-Policy, X-Content-Type-Options, Referrer-Policy, and — when IDEN_ENV=prodStrict-Transport-Security on every response. If your proxy adds its own copy of any of these, remove one of the two. Two headers stating one policy is a place for them to disagree.

Responses carrying a token are Cache-Control: no-store (RFC 6749 Section 5.1). The two exceptions are discovery and JWKS, at public, max-age=300, because every resource server fetches the key set and making it uncacheable would put IDEN in the path of every token validation your APIs do.