Self-host Zitadel on a VPS with Docker
Zitadel wants 4 cores and 8GB. Set up Postgres, the masterkey, TLS, SMTP and backups on one VPS, and learn what an upgrade does to your database.
What you need to self-host Zitadel on a VPS
To self-host Zitadel on a VPS you need a Docker host, a public DNS name pointing at it, PostgreSQL, and around 4 CPU cores with 8 GB of RAM. Zitadel is an identity provider. It issues tokens over OIDC (OpenID Connect) and SAML (security assertion markup language) so your other services stop keeping their own user lists. The install is a curl and a docker compose up. The parts that decide whether it survives are the masterkey, the database user, SMTP (simple mail transfer protocol), the backup, and the first upgrade.
Everything below assumes Ubuntu 24.04, Docker Engine 24 or newer with the Compose plugin, and a name like auth.example.com already resolving to the server.
How much VPS does Zitadel need?
The Compose quickstart in Zitadel's docs asks for 2 GB of RAM. That number is for a laptop. Zitadel's production guide publishes different figures.
The data behind this chart
[
{
"config": "Process floor, no load",
"cpu_cores": 0.5,
"ram_gb": 0.5
},
{
"config": "Single node, reduced setup",
"cpu_cores": 4,
"ram_gb": 8
},
{
"config": "HA node, logs and metrics on",
"cpu_cores": 4,
"ram_gb": 16
}
]Those are published recommendations, not measurements from a running box. Read them as the shape of the problem. The Zitadel process itself is small, about 0.5 GB of RAM at rest. The cores are for password hashing, which is deliberately slow, so a burst of logins arrives as a CPU spike. PostgreSQL is the other half of the bill: the same guide budgets about one core per 100 requests per second and 4 GB of RAM per core. Put the two together and you land on the 4 cores and 8 GB the guide names for a single node, or 16 GB per node once logging and metrics are on.
So a 2 GB VPS will start this stack, and it is below what the project recommends for anything real. Login is the service every other service depends on. When it is down, nothing that trusts it lets anyone in. Deciding that 8 GB is more than you want to spend on authentication is a reasonable call, and it is far cheaper to make now than after a migration. The comparison of Keycloak, Authentik and Zitadel covers what each one costs in memory and in operational work, and a self-hosted Authentik server is the usual answer on a smaller box.
Get the stack and pin a version
mkdir zitadel-compose && cd zitadel-compose
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.yml
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/.env.example
cp .env.example .env
chmod 600 .envThat file defines four services you will actually run. Traefik is the reverse proxy: it routes by path and, with the overlay further down, terminates TLS (transport layer security). zitadel-api is the Go binary on port 8080. zitadel-login is the login interface served at /ui/v2/login. postgres holds everything. A Redis cache and an OpenTelemetry collector sit in the same file behind Compose profiles and stay off until you ask for them.
Do not run docker compose up yet. The first start creates the instance, and several settings below cannot be changed afterwards without extra work.
The .env you copied pins its own image tags:
ZITADEL_VERSION=v4.16.0
TRAEFIK_IMAGE=traefik:v3.7.7
POSTGRES_IMAGE=postgres:17.10-alpineThe current v4 release is v4.17.1, published on 14 August 2026. Set ZITADEL_VERSION to the version you mean to run, and stay on the v4 line rather than tracking whatever is newest. The curl above pulls docker-compose.yml from the main branch, which is pinned to nothing, so commit your copy of both files to a git repository. Otherwise the same command on a new box next month gives you a different file and you will not know what changed.
Give Postgres its own user and a real password
The shipped .env connects Zitadel to PostgreSQL as the superuser, with the password postgres:
POSTGRES_ADMIN_USER=postgres
POSTGRES_ADMIN_PASSWORD=postgres
ZITADEL_DATABASE_POSTGRES_DSN=postgresql://postgres:postgres@postgres:5432/zitadel?sslmode=disableThere is a trap in the hardening step here. Zitadel's docs tell you to append POSTGRES_ZITADEL_PASSWORD to .env, but the base docker-compose.yml never reads that variable, so setting it changes nothing. Changing POSTGRES_ADMIN_PASSWORD on its own breaks the connection instead, because the password is also written literally inside the DSN (data source name) string. The DSN is the line that decides how Zitadel connects.
The comments in .env.example say the rest plainly: when a DSN is configured, Zitadel uses that user directly and does not create an unprivileged one for you, so the role has to exist before the first start. Generate a password, start Postgres on its own, and create the role.
tr -dc A-Za-z0-9 </dev/urandom | head -c 32; echo
docker compose --env-file .env -f docker-compose.yml up -d postgres
docker compose --env-file .env -f docker-compose.yml exec -T postgres \
psql -U postgres -d postgres <<'SQL'
CREATE ROLE zitadel LOGIN PASSWORD 'the-password-you-generated';
ALTER DATABASE zitadel OWNER TO zitadel;
SQL
docker compose --env-file .env -f docker-compose.yml exec -T postgres \
psql -U postgres -d zitadel -c 'ALTER SCHEMA public OWNER TO zitadel;'Those psql calls run inside the container over its local socket, which the official Postgres image trusts, so they do not prompt for a password. Ownership is the part that matters. On PostgreSQL 15 and newer a plain GRANT ALL PRIVILEGES ON DATABASE no longer lets a role create tables in the public schema, so Zitadel's setup phase fails with a permission error while building its schemas. Making the role own the database and the schema avoids that.
Now point the DSN at the new role, and set a real admin password while you are in the file:
POSTGRES_ADMIN_PASSWORD=a-32-character-random-string
ZITADEL_DATABASE_POSTGRES_DSN=postgresql://zitadel:the-password-you-generated@postgres:5432/zitadel?sslmode=disablesslmode=disable is fine here because Postgres is only reachable on the private Compose network and its port is never published to the host. After the first full start, check that the role really owns its data:
docker compose exec -T postgres psql -U zitadel -d zitadel -c '\dn'That should list an eventstore schema and a projections schema. An empty list means the setup phase never got that far, and the API container log will say why.
The masterkey, and what losing it costs
Zitadel encrypts secrets before it stores them: client secrets, identity provider credentials, the SMTP password, one-time-password seeds and machine keys. The masterkey unlocks all of it. It is exactly 32 characters, and the docs are blunt about the consequence: it cannot be changed without losing access to encrypted data.
Generate one and replace the placeholder line in .env:
tr -dc A-Za-z0-9 </dev/urandom | head -c 32; echoEdit the ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters line rather than appending a second one. Compose takes the last definition of a repeated key, so appending does work, but a file holding two masterkey lines is a trap for whoever reads it next.
Now think about where that key lives. The compose file starts the API container like this:
command: start-from-init --masterkey "${ZITADEL_MASTERKEY}"The masterkey therefore sits on the container command line, where docker inspect shows it to anyone who can reach the Docker socket. On a single-admin VPS that is an acceptable trade, and the mode on .env is what protects it on disk. If it is not acceptable, mount the key as a file and use --masterkeyFile /run/secrets/zitadel-masterkey instead, which keeps the value out of process arguments.
Copy the masterkey into your password manager before the first start. It does not appear in a database dump, so a dump restored under a different masterkey produces an instance that cannot read its own secrets. Keep it somewhere other than the archive that holds the dump, so one stolen backup does not contain both the encrypted data and the key to it.
Set the external domain before the first start
ZITADEL_DOMAIN in .env feeds ZITADEL_EXTERNALDOMAIN in the container, and it is the name your users type. Zitadel derives the OIDC issuer, the login interface base URI, the SAML endpoints and the first admin's login name from it, so it is not cosmetic.
ZITADEL_DOMAIN=auth.example.com
ZITADEL_EXTERNALPORT=443
ZITADEL_EXTERNALSECURE=trueZitadel resolves which instance you are talking to from the Host header. If that header does not match a domain it knows, every request gets the same answer:
ID=QUERY-1kIjX Message=Instance not foundThat is the most common self-hosted Zitadel error, and it almost always means one of two things. Either ZITADEL_DOMAIN is not the name you are browsing to, or a proxy in front is rewriting Host to the upstream address. Browsing to the server's IP address instead of the name produces it as well.
You can change these values later. Zitadel has to rerun its setup phase to pick the change up, and every application you already registered keeps its old redirect URIs. Choosing the final name now is much cheaper than moving it.
Terminate TLS with the Let's Encrypt overlay
For a public domain, add Zitadel's Let's Encrypt overlay. It switches Traefik to the ACME (automatic certificate management environment) HTTP challenge and replaces the published ports with 80 and 443, so nothing else on the box may hold either one.
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.mode-letsencrypt.yml
echo 'LETSENCRYPT_EMAIL=ops@example.com' >> .envThe overlay also sets ZITADEL_EXTERNALPORT: 443 and ZITADEL_EXTERNALSECURE: true on the API container, which is why the public URL and the URLs Zitadel builds for itself agree. The A record has to resolve before you start, because the HTTP challenge fails without it.
If you already terminate TLS on nginx or on a load balancer, use docker-compose.mode-external-tls.yml instead and set TRAEFIK_TRUSTED_IPS to the ranges your proxy sends from. Traefik only honours X-Forwarded-* headers from addresses in that list, so a wrong value means the forwarded protocol is dropped and Zitadel starts building http:// URLs for an HTTPS site.
An upstream proxy has two jobs Zitadel is strict about. It must speak HTTP/2 to the backend, because the API is gRPC. And it must pass Host through unchanged along with X-Forwarded-Proto: https. Zitadel's own nginx example shows the shape:
server {
listen 443 ssl;
http2 on;
ssl_certificate /etc/certs/selfsigned.crt;
ssl_certificate_key /etc/certs/selfsigned.key;
location /ui/v2/login {
proxy_pass http://login-external-tls:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
}
location / {
grpc_pass grpc://zitadel-external-tls:8080;
grpc_set_header Host $host;
grpc_set_header X-Forwarded-Proto https;
}
}The upstream names there are the containers in Zitadel's test setup, so replace them with yours. If you serve Zitadel on a port other than 443, use grpc_set_header Host $host:$server_port; so the port travels with the header. The rest is an ordinary virtual host, and an nginx reverse proxy config read line by line covers the parts that are not Zitadel-specific.
The first admin, and forcing the password change
The first start creates one instance, one organisation and one human admin. The login name is zitadel-admin@ plus zitadel. plus your external domain, so with ZITADEL_DOMAIN=auth.example.com it is:
zitadel-admin@zitadel.auth.example.comThe password is Password1! unless you set your own. Zitadel's upstream default is to force a change at first login, and the shipped compose file overrides that default:
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: falseThat line is hardcoded in docker-compose.yml rather than read from .env, so put your own values in a small overlay of your own. Call it docker-compose.local.yml:
services:
zitadel-api:
environment:
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_EMAIL_ADDRESS: you@example.com
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: "a-long-temporary-password"
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "true"Compose only loads docker-compose.override.yml on its own when you run it with no -f flag, and every command in Zitadel's guide passes -f, which turns that off. Rather than repeating a growing list of flags, pin the file list in .env:
COMPOSE_FILE=docker-compose.yml:docker-compose.mode-letsencrypt.yml:docker-compose.local.ymlNow start it:
docker compose pull
docker compose up -d --wait--wait holds the command until the healthchecks pass. When the API container never gets there, Compose stops with dependency failed to start: container zitadel-compose-zitadel-api-1 is unhealthy, and docker compose logs zitadel-api has the reason. On a first start the reason is usually the masterkey length or the database DSN.
Log in at https://auth.example.com/ui/console, change the password, then turn on a second factor for that account before you create anything else. Every ZITADEL_FIRSTINSTANCE_* value applies only while the first instance is being created. Once the instance exists, editing them does nothing at all.
Why password reset does nothing until SMTP works
An identity provider that cannot send mail is broken in a way that stays hidden for weeks. Zitadel sends email for user invitations, address verification, password reset links, one-time codes and domain claim notices. With no SMTP provider configured, the Console still reports the action as done, and the message goes to a notification worker with nowhere to send it. The defaults give that worker MaxAttempts: 3 and MaxTtl: 5m, so it retries a few times over a few minutes and then stops. Nothing tells the person waiting for the link.
Configure it in the Console, under instance settings at https://auth.example.com/ui/console/settings. The SMTP provider form asks for a sender email address, a sender name, the host and port, a user, an SMTP password and a TLS toggle. Use the test button in that form before you save, because it sends a real message: it either arrives or it does not.
There is a matching set of environment variables, ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_HOST and its siblings. They apply when an instance is created. On a stack that is already running they have no effect, so the Console is the right place for an existing instance.
Two things about delivery from a VPS, because this is where it usually fails. Most providers block outbound port 25 on new accounts, so a direct send to the recipient's mail server times out with no useful error. Use an authenticated relay on port 587 instead. And publish SPF (sender policy framework) and DKIM (domainkeys identified mail) records for the sending domain, or the reset link lands in spam, which looks to the user exactly like the mail never being sent.
Prove it before you invite anyone. Create a throwaway user, ask for a password reset, and watch the message arrive. If it does not, docker compose logs -f zitadel-api names the SMTP failure. The SMTP password is stored encrypted in the database, which is one more thing the masterkey is holding for you.
Back up Postgres and the masterkey separately
Everything Zitadel knows is in PostgreSQL. The thing that decrypts it is the masterkey. Back them up to two different places.
The dump first:
sudo install -d -m 700 /srv/zitadel-backups
docker compose exec -T postgres \
pg_dump -U postgres -Fc zitadel > "/srv/zitadel-backups/zitadel-$(date +%F).dump"-Fc is the custom format, which compresses on the way out and which pg_restore can read selectively. exec -T drops the terminal, which matters because this runs from cron with no terminal attached.
Then push that directory offsite with restic, which encrypts and deduplicates:
export RESTIC_REPOSITORY="sftp:backup@backup.example.com:/srv/restic/zitadel"
export RESTIC_PASSWORD_FILE=/root/.restic-password
restic init
restic backup /srv/zitadel-backups
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prunerestic init runs once, on the first day only. Put the dump and the last two commands in /usr/local/bin/zitadel-backup.sh and run it nightly:
0 3 * * * /usr/local/bin/zitadel-backup.shBack up .env and every compose file you use, in git. The masterkey is the exception to all of this. It belongs in your password manager and in a second location that is not this restic repository, because an archive holding the database and its decryption key together stops being a backup of an encrypted system.
A backup you have not restored is a guess. Restore into a scratch database on the same server and look at it:
docker compose exec -T postgres createdb -U postgres zitadel_restore_test
docker compose exec -T postgres pg_restore -U postgres -d zitadel_restore_test \
< /srv/zitadel-backups/zitadel-2026-08-21.dump
docker compose exec -T postgres psql -U postgres -d zitadel_restore_test -c '\dt eventstore.*'
docker compose exec -T postgres dropdb -U postgres zitadel_restore_testA list of tables in the eventstore schema means the dump is real. An error saying the schema does not exist means it is not, and you have found that out on a day when it costs you nothing. The general pattern for backing up and upgrading a Compose stack applies here almost unchanged, and keeping the masterkey out of the same archive is the only Zitadel-specific part.
Upgrading Zitadel without losing the instance
An upgrade is a version bump in .env followed by two commands:
docker compose pull
docker compose up -d --waitUnderstand what the second one does before you run it against something people log in to. The container's command is start-from-init, which runs the init and setup phases before it starts serving, and the setup phase is the database migrations. So a version bump runs schema migrations against your live database at container start, unattended, while --wait sits there waiting on a healthcheck. That is the whole reason the restore test above is not optional.
Take a fresh dump immediately before the upgrade. Last night's dump is a different thing.
Do not jump a major version. Moving from v3 to v4 requires being on v3.4.1 or later first, because v4 removed the legacy OIDC signing keys, so tokens signed with the old keys stop verifying the moment you cross over. Zitadel's technical advisory A-10017 describes it, and the fix is to run the newer v3 long enough for old tokens to expire before you upgrade.
Watch the setup phase with docker compose logs -f zitadel-api. Migrations on a large eventstore take minutes, and Traefik will not route to the API until its healthcheck passes, so the site is down for that window. Plan it rather than discovering it.
Rollback is not a matter of putting the old tag back. Once the migrations have run, the older binary does not understand the schema it finds, so rolling back means restoring the dump. Once the instance carries real users, move to docker-compose.prodlike.yml, the overlay that runs init and setup as steps separate from start, so a migration is something you trigger and watch instead of a side effect of a container restart.
What to point at your new identity provider
In the Console, create a project and then an application inside it. Pick OIDC for anything modern, and Zitadel gives you a client ID, a client secret and a discovery document at https://auth.example.com/.well-known/openid-configuration. Most self-hosted software that supports single sign-on wants exactly those.
Plenty of software does not support it, or supports it only in a paid tier. For the first case, oauth2-proxy in front of the app turns any HTTP service into something Zitadel can guard. For the second, the SSO tax in self-hosted apps is worth reading before you plan a migration around a feature you have not paid for.
FAQ
How much RAM and CPU does a self-hosted Zitadel need?
Zitadel's production guide recommends about 4 CPU cores and 8 GB of RAM for a single node running a reduced setup, and 16 GB per node with logging and metrics on. PostgreSQL is budgeted separately, at roughly one core per 100 requests per second and 4 GB of RAM per core. The Compose quickstart starts inside 2 GB, which is enough to try it and below what the project recommends for a system other services depend on.
What happens if I lose the Zitadel masterkey?
Everything encrypted with it stays encrypted. Client secrets, identity provider credentials, the SMTP password and one-time-password seeds cannot be decrypted, and the key cannot be changed after the fact. A database dump on its own does not restore a working instance, because the dump holds ciphertext and no key. Store the masterkey in a password manager, in a place separate from the backup that holds the dump. If both are gone, rebuilding the instance from scratch is the only path left.
Why do Zitadel password reset emails never arrive?
Because no SMTP provider is configured, or the configured one cannot deliver. Zitadel queues each notification to a worker with three attempts by default and reports success in the Console either way, so the failure is silent. Configure the SMTP provider under instance settings and use the test button in that form, which sends a real message. From a VPS, use an authenticated relay on port 587, since most providers block outbound port 25, and publish SPF and DKIM records for the sending domain so the mail is not filtered as spam.
Can I change the Zitadel external domain after installing?
Yes, but not by editing .env alone. Change ZITADEL_EXTERNALDOMAIN, ZITADEL_EXTERNALPORT and ZITADEL_EXTERNALSECURE, then let Zitadel rerun its setup phase so it picks the change up. Applications you already registered keep their old redirect URIs and have to be updated by hand, and any request whose Host header does not match a domain Zitadel knows gets Instance not found back. Choosing the final name before the first start avoids all of it.