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:
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¶
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:
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 root — IDEN_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¶
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.
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=prod — Strict-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.