How to Deploy Zitadel on a VPS
Updated Sep 2026
verified on Ubuntu 26.04 · Sep 2026Self-host Zitadel on a VPS with the official Docker Compose stack. Generate the masterkey and other secrets before the first start, set the external URL correctly, and put Caddy in front for TLS over end-to-end HTTP/2.
- A VPS with at least 2 GB RAM (upstream's stated minimum for the Compose stack)
- A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
- A domain for the identity server, such as auth.example.com
- Docker Engine + Compose installed (see the base guide below)
What Zitadel is
Zitadel is an open-source identity and access management platform written in Go and licensed AGPL-3.0. It provides OpenID Connect, SAML and passwordless login, with multi-tenancy built in: one instance holds many organizations, each with its own users, projects and branding. That makes it a common choice for B2B SaaS apps that need to sign in customers' users as well as their own.
Since version 4, a Zitadel deployment has two application containers:
zitadel-api, the Go server with the APIs, OIDC/SAML endpoints and the management console;zitadel-login, a separate Next.js login UI.
The official Compose stack puts both behind a bundled Traefik proxy, with
PostgreSQL underneath. This guide uses that stack unchanged. It adjusts the
settings in .env so that Traefik listens only on the loopback interface and a
Caddy server on the host handles the public domain and TLS.
To compare it with other identity servers, see Keycloak vs Zitadel and Authentik vs Zitadel.
Server sizing
Zitadel's Compose guide asks for a machine with at least 2 GB RAM, and the catalog uses the same figure. Measured idle on our test box, the whole stack (API, login UI, Traefik and PostgreSQL) used about 235 MB of RAM and 1.2 GB of disk. The 2 GB figure leaves room for login bursts, database growth and the OS. A 2 vCPU / 2–4 GB instance is a sensible starting point.
Prepare the server
This guide assumes Docker Engine, the Compose plugin and a ufw firewall are
set up. If they aren't, see
Docker & Compose on Ubuntu. Only SSH and
the public web ports are opened:
sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw status verbose
Point an A record for auth.example.com at the server now, so DNS has time
to propagate while you install.
Paid link — we earn a commission if you shop through it.
Install Zitadel (official Compose stack)
Download the compose file and the example environment from upstream:
mkdir -p ~/zitadel && cd ~/zitadel
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
grep -E '^(ZITADEL_VERSION|POSTGRES_IMAGE)=' .env.example
The .env.example file pins the Zitadel and PostgreSQL versions, and it opens
with a warning: its secrets are insecure defaults for local development.
Replace them before the first start. That matters most for the masterkey.
Zitadel encrypts sensitive data at rest with it, and upstream warns that it
can't be changed after initialization without losing access to that data.
The block below creates .env from the example once, and only if .env
doesn't exist yet. It then:
- generates a 32-character masterkey, a login-cookie secret and a database password;
- sets the public URL to HTTPS on port 443 at your domain;
- binds the bundled Traefik to
127.0.0.1:8080so that only Caddy can reach it.
if [ ! -f .env ]; then
cp .env.example .env
PGPASS=$(openssl rand -hex 24)
sed -i \
-e "s|^ZITADEL_DOMAIN=.*|ZITADEL_DOMAIN=auth.example.com|" \
-e "s|^PROXY_HTTP_PUBLISHED_PORT=.*|PROXY_HTTP_PUBLISHED_PORT=127.0.0.1:8080|" \
-e "s|^ZITADEL_EXTERNALPORT=.*|ZITADEL_EXTERNALPORT=443|" \
-e "s|^ZITADEL_EXTERNALSECURE=.*|ZITADEL_EXTERNALSECURE=true|" \
-e "s|^ZITADEL_PUBLIC_SCHEME=.*|ZITADEL_PUBLIC_SCHEME=https|" \
-e "s|^ZITADEL_MASTERKEY=.*|ZITADEL_MASTERKEY=$(openssl rand -hex 16)|" \
-e "s|^LOGIN_SESSION_COOKIE_SECRET=.*|LOGIN_SESSION_COOKIE_SECRET=$(openssl rand -hex 32)|" \
-e "s|^POSTGRES_ADMIN_PASSWORD=.*|POSTGRES_ADMIN_PASSWORD=$PGPASS|" \
-e "s|postgres:postgres@postgres|postgres:$PGPASS@postgres|" \
.env
fi
chmod 600 .env
grep -E '^(ZITADEL_DOMAIN|PROXY_HTTP_PUBLISHED_PORT|ZITADEL_EXTERNALPORT|ZITADEL_EXTERNALSECURE|ZITADEL_PUBLIC_SCHEME)=' .env
The three external URL settings matter most: ZITADEL_DOMAIN,
ZITADEL_EXTERNALPORT and ZITADEL_EXTERNALSECURE. They must describe the URL
users actually type, which is https://auth.example.com on port 443, not the
internal 127.0.0.1:8080. Upstream calls a mismatch here the most common
deployment problem. It shows up as "Instance not found" errors. Replace
auth.example.com with your own domain before the first start, because
Zitadel creates its first instance for that domain.
Start the stack. --wait returns once every container reports healthy:
docker compose up -d --wait
docker compose ps
The API container runs start-from-init: on first boot it creates the database
schema and the first instance, then starts serving. Check the stack locally
before you add TLS. Traefik routes by hostname, so send the public host name
in the request:
curl -s -H 'Host: auth.example.com' http://127.0.0.1:8080/.well-known/openid-configuration | head -c 300; echo
curl -s -o /dev/null -w 'login UI over h2c: HTTP %{http_version} %{http_code}\n' --http2-prior-knowledge -H 'Host: auth.example.com' http://127.0.0.1:8080/ui/v2/login/
The discovery document's issuer should read https://auth.example.com. The
second check shows that the bundled Traefik answers cleartext HTTP/2 (h2c). The
proxy in front of it needs that.
HTTPS + domain via Caddy
Zitadel's documentation states that the management console needs end-to-end HTTP/2. Caddy already serves HTTP/2 to browsers over TLS. For the hop to the bundled Traefik, tell it to use h2c. Install Caddy as described in Automatic HTTPS with Caddy, then add:
auth.example.com {
reverse_proxy h2c://127.0.0.1:8080
}
Caddy gets the certificate, terminates TLS, and forwards everything, including
gRPC and the console's streaming calls, to Traefik over HTTP/2. Traefik then
routes /ui/v2/login to the login container and everything else to the API.
If you would rather have no Caddy at all, upstream ships a
docker-compose.mode-letsencrypt.yml overlay. With it, the bundled Traefik
publishes ports 80 and 443 and gets certificates itself. Don't combine that
overlay with Caddy on the same host, because both would want ports 80 and 443.
First login
Open https://auth.example.com/ui/console. The first instance comes with an
admin user named zitadel-admin@zitadel.auth.example.com (the pattern is
zitadel-admin@zitadel.<your domain>). Its password is Zitadel's documented
default, Password1!. The stack sets "password change required" to false
for this user, so change the password yourself right away: open your
profile in the console and set a new one. Then add a second factor. Until you do
this, anyone who knows Zitadel's defaults can sign in to your instance as admin.
ZITADEL_FIRSTINSTANCE_* and ZITADEL_DEFAULTINSTANCE_* variables only apply
during the first start. After that, change settings in the console or
through the Admin API, not in .env.
Securing it
- Back up
.envsomewhere safe as soon as the stack starts. It holds the masterkey. If you lose it, the encrypted data in the database can't be read. - Change the default admin password (above), and require MFA for administrators under the instance's login policy.
- Keep Traefik on the loopback interface.
PROXY_HTTP_PUBLISHED_PORT=127.0.0.1:8080means only Caddy can reach the stack. Check withsudo ss -ltnp | grep 8080. - Keep the pinned versions.
.envpinsZITADEL_VERSIONand the database image, so the stack never upgrades without you changing a line. - For production-grade setups, upstream documents a
docker-compose.prodlike.ymloverlay that runs migrations in one-shotzitadel-initandzitadel-setupcontainers instead of on every API start.
Backups
All state is in PostgreSQL, but the database is only useful with the masterkey that encrypted it. Back up both together:
cd ~/zitadel
docker compose exec -T postgres pg_dump -U postgres zitadel | gzip > zitadel-db-$(date +%F).sql.gz
tar czf zitadel-config-$(date +%F).tar.gz .env docker-compose.yml
ls -lh zitadel-db-*.sql.gz zitadel-config-*.tar.gz
The config archive holds the masterkey and database password in plain text. Encrypt it before it leaves the box, and store it separately from the database dump. Anyone who has both can decrypt everything.
Upgrades
Upstream's upgrade procedure is to edit ZITADEL_VERSION in .env, then pull
and restart:
cd ~/zitadel
docker compose --env-file .env -f docker-compose.yml pull
docker compose --env-file .env -f docker-compose.yml up -d --wait
Take a database backup first and read the release notes. Zitadel runs its
database migrations on startup, and a migrated database can't simply be moved
back to an older version. The compose file and .env.example on main change
over time too. If you download newer copies, compare them with yours before you
replace anything.
Troubleshooting
"Instance not found." The public URL doesn't match ZITADEL_DOMAIN,
ZITADEL_EXTERNALPORT or ZITADEL_EXTERNALSECURE. With Caddy on 443 these
must be your domain, 443 and true. Because the first instance is created
for the domain configured on the first start, changing the domain later needs
more than editing .env. Upstream's custom-domain docs cover it.
The console loads but hangs or shows gRPC errors. Some hop isn't HTTP/2.
Make sure the Caddy block uses h2c://, not a plain http:// upstream.
docker compose up --wait times out. Run docker compose logs zitadel-api.
On first boot, a masterkey that isn't exactly 32 characters or a DSN with the
wrong database password stops initialization. Fix .env, then run
docker compose down -v only if no real data exists yet, and start again.
The login page returns errors after a restart. The login container reads a
personal access token from the shared zitadel-bootstrap volume. It is written
during first init. Don't delete that volume on its own.
Verification + next steps
You're done when:
https://auth.example.com/.well-known/openid-configurationloads with anhttps://issuer and a valid certificate;- the console works at
https://auth.example.com/ui/console; - the default admin password is gone and MFA is on;
- a database dump and an encrypted copy of
.envare stored off the box.
From there, create an organization and a project, then register your first OIDC application. For a lighter login screen in front of a few homelab apps, Pocket ID or Tinyauth needs far less setup. For a heavier server with LDAP federation, see Keycloak.