>_devkit
zitadel-production
skills/zitadel-production/

references/operations.md

Operations

Backups

The masterkey is not in the database

ZITADEL_MASTERKEY lives in the environment. ZITADEL uses it to encrypt secrets at rest: client secrets, identity provider credentials, OTP seeds, machine user keys.

A database backup without the matching masterkey restores perfectly and cannot decrypt anything in it.

So the backup unit is a pair: the dump, plus the masterkey in effect when it was taken. Store the masterkey in a secrets manager or password manager, labelled with the deployment it belongs to. Running several client instances makes mixing them up easy, and you would discover it during an incident.

There is no clean rotation path for the masterkey. Treat a leaked one as requiring a fresh deployment.

Otherwise it is ordinary Postgres

ZITADEL is well behaved here. It is event-sourced: eventstore.events is the source of truth and projections are derived, so a restore rebuilds cleanly with no half-applied-state failure mode.

# backup
docker exec -t <db-container> pg_dump -U postgres -Fc zitadel > zitadel-$(date +%F).dump

# restore
pg_restore -U postgres -d zitadel --clean --if-exists zitadel-2026-07-25.dump

pg_dump runs in a single transaction snapshot, so it is consistent without stopping the service.

Choices worth making deliberately

Dumps or PITR. A dump taken immediately before an upgrade is a perfectly good rollback, and that is the case you will actually hit. WAL archiving and point-in-time recovery is for disaster recovery, where "restore to ten minutes before the bad thing" matters. Managed Postgres gives you PITR for free, which is most of the argument for choosing it over a container.

Avoid live volume snapshots unless the storage layer does atomic snapshots. Postgres can usually crash-recover from one, but pg_dump or pg_basebackup removes the doubt.

Retention grows. The eventstore is append-only and grows continuously rather than reaching a steady state. Plan retention accordingly.

Verify a restore

Once, properly, into a scratch instance: restore the dump, supply the masterkey, log in. An untested backup is a hypothesis, and the masterkey pairing in particular is exactly the kind of thing you discover you got wrong at the worst possible moment.

Ship a backup.sh with the template that dumps the database and prints which masterkey the dump belongs to, plus a runbook line saying where that key is stored.

Upgrades

What ZITADEL gives you

A two-phase upgrade. zitadel setup runs migrations, zitadel start runs the server. Splitting them is the point: migrate deliberately, then roll the runtime. start-from-init does both at once, which is right for a fresh deploy and wrong for a controlled upgrade. Both phases are idempotent for a given binary version, and setup resumes where a previous run stopped rather than starting over.

Upgrade complexity labels in release notes:

LabelMeaning
MinorInstance-level tables. Cheap for self-hosted, where there is one instance
IntermediateOrganization tables needing re-projection, or new indexes on the events table
MajorProjections over large event volumes: users, project grants
DowntimeZero-downtime upgrade is not possible

That last row is the field you check before promising a client a maintenance window.

Technical Advisories, ZITADEL's official notices for security issues, breaking changes and required downtime. Subscribe to them rather than skimming at upgrade time.

Skipping versions works but saves less than expected. Most intermediate migration steps still execute and their costs compound. Only repeated migrations of the same projection collapse into one.

What it does not give you

Rollback. Migrations are not reversible. A verified Postgres backup taken immediately before is the only undo that exists. This is the single most important sentence in this file.

The playbook for N clients

  1. Read the release notes' complexity label and any advisories
  2. Upgrade your own instance first, as a canary
  3. Per client: snapshot Postgres, run setup, verify, then start
  4. Roll one client at a time

Per-client deployments are an upgrade advantage. You canary for free, stage rollouts, and one client's failure never touches another. A shared instance forces big-bang upgrades gated by your most conservative client.

Script it so it is the same four commands everywhere: bump tag, back up, setup, start, health-check. That is what makes per-client sustainable at ten clients rather than two.

Scaling

The two halves scale in opposite directions, which is the part worth knowing before you plan around it.

ZITADEL: horizontal, trivially

The binary is stateless; all shared state is in Postgres. Run N replicas behind the load balancer. Production wants at least two, for availability rather than throughput: one pod dying should not take down login.

Postgres: vertical, and currently that is the only option

ZITADEL cannot yet offload reads to a replica. Separate read-only database connections are an open feature request (#9109). You scale Postgres by making it bigger, not by adding more.

That limit is sharper than it sounds because ZITADEL is unusually write-heavy. Being event-sourced, every login, token issuance and session change appends to the eventstore. Read replicas would help less here than in a typical CRUD application even once they land.

The order that actually works

  1. Give Postgres enough RAM to cache the working set. The biggest single win.

ZITADEL's own guidance is that a Postgres with sufficient memory can absorb large traffic loads without the operational overhead of a separate Redis cluster.

  1. Tune the connection pool: MaxOpenConns and MaxIdleConns (defaults

around 20 and 10), MaxConnLifetime, MaxConnIdleTime.

  1. Add PgBouncer once several ZITADEL replicas run, since each opens its own

pool and they multiply.

  1. Then vertical CPU and IOPS on Postgres.

The reality check

You will almost certainly do none of this. ZITADEL Cloud's Pro tier covers 25,000 daily active users on standard infrastructure. A typical client application has hundreds, perhaps low thousands. One container and one Postgres handles that with room to spare.

The effort pays off instead on the boring side: backups with point-in-time recovery, monitoring, and a scripted upgrade path. Those matter on day one at every client. Horizontal scaling matters at none of them for a long time.

Per-client database choice

Prefer managed Postgres over a container, for the backups and PITR rather than for scale. For a small client, pointing ZITADEL at a separate database on the application's existing Postgres instance is a reasonable saving: you are coupling their availability, which at that size is a fine trade.

Reach for a dedicated instance when data residency or compliance says so, not when traffic does. Traffic will not, for years.

Licence and cost

ZITADEL is AGPL-3.0, and cloud and self-hosted run the same codebase with no feature gating. Self-hosting is free; the pricing page lists it only under Enterprise, which reads as though a contract is required. It is not. Enterprise buys support, an SLA and a commercial licence.

Two AGPL points that matter for an agency:

  • It does not affect your client's application code. Your app talks to ZITADEL

over OIDC, which is a network protocol between separate programs, not linking. Running upstream unmodified, you satisfy the section 13 network obligation by pointing at the public repository. Configure it, do not fork it.

  • Some enterprises ban AGPL outright, as blanket policy with no argument

available. That is exactly what the Enterprise commercial licence exists for, and you will eventually meet a procurement team that needs it.

Cloud pricing is per daily active user, which is unusually generous when most vendors bill monthly actives. There is a free tier, and the paid tier includes a large DAU allowance.

The economics for an agency: the cost belongs on the client's bill, not yours, which is the whole point of per-client isolation. Self-hosted is roughly the price of a small container plus Postgres. Do not self-host purely to save a subscription if it costs you billable hours; do self-host when the client needs to own the box.