Skip to content

How to Deploy Zitadel on a VPS

Updated Sep 2026

verified on Ubuntu 26.04 · Sep 2026
We earn commissions when you shop through the links below. Full disclosure →

Self-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.

Before you start
  • 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)
Need a box for this guide? Kamatera's free tier lets you spin one up now.Start free on Kamatera → (opens in new tab)

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.

Where to host itaffiliate disclosure
Hetzner Cloudrun it on
2 vCPU · 4 GB RAM · 80 GB SSD · $23.59/mo
Get Hetzner Cloud (opens in new tab)
Kamaterafree trial
1 vCPU · 1 GB RAM · 20 GB SSD · $4.00/mo
Start free on Kamatera → (opens in new tab)
DigitalOceanalso works on
1 vCPU · 1 GB RAM · 25 GB SSD · $6.00/mo
Deploy on DigitalOcean → (opens in new tab)

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:8080 so 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 .env somewhere 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:8080 means only Caddy can reach the stack. Check with sudo ss -ltnp | grep 8080.
  • Keep the pinned versions. .env pins ZITADEL_VERSION and the database image, so the stack never upgrades without you changing a line.
  • For production-grade setups, upstream documents a docker-compose.prodlike.yml overlay that runs migrations in one-shot zitadel-init and zitadel-setup containers 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-configuration loads with an https:// 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 .env are 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.

Next steps

How to self-host Zitadel →More self-hosted sso & identity tools →Best VPS for Keycloak →Automatic HTTPS with Caddy →Run Claude Code with Ollama on Your Own VPS →Deploy Coolify on a VPS →How to Deploy Actual Budget on a VPS →How to Deploy AnythingLLM on a VPS →How to Deploy Appwrite on a VPS →How to Deploy Audiobookshelf on a VPS →How to Deploy Authelia on a VPS →How to Deploy authentik on a VPS →How to Deploy Baserow on a VPS →How to Deploy Beszel on a VPS →How to Deploy Bitwarden on a VPS →How to Deploy BookStack on a VPS →How to Deploy CapRover on a VPS →How to Deploy Checkmate on a VPS →How to Deploy Directus on a VPS →How to Deploy docker-mailserver on a VPS →How to Deploy Docmost on a VPS →How to Deploy Dokku on a VPS →How to Deploy Dokploy on a VPS →How to Deploy Firefly III on a VPS →How to Deploy Forgejo on a VPS →How to Deploy Gatus on a VPS →How to Deploy Ghostfolio on a VPS →How to Deploy Gitea on a VPS →How to Deploy GitLab on a VPS →How to Deploy GlitchTip on a VPS →How to Deploy Grafana on a VPS →How to Deploy Graylog on a VPS →How to Deploy Headscale on a VPS →How to Deploy Healthchecks on a VPS →How to Deploy Home Assistant on a VPS →How to Deploy Immich on a VPS →How to Deploy Jan on a VPS →How to Deploy Jellyfin on a VPS →How to Deploy Karakeep on a VPS →How to Deploy Keycloak on a VPS →How to Deploy Leantime on a VPS →How to Deploy LibreChat on a VPS →How to Deploy Linkwarden on a VPS →How to Deploy LocalAI on a VPS →How to Deploy Mailcow on a VPS →How to Deploy Mailu on a VPS →How to Deploy Matomo on a VPS →How to Deploy Mattermost on a VPS →How to Deploy Meilisearch on a VPS →How to Deploy Memos on a VPS →How to Deploy n8n on a VPS →How to Deploy Navidrome on a VPS →How to Deploy NetBird on a VPS →How to Deploy Netdata on a VPS →How to Deploy Nextcloud on a VPS →How to Deploy Next.js to a VPS →How to Deploy Nginx Proxy Manager on a VPS →How to Deploy NocoDB on a VPS →How to Deploy ntfy on a VPS →How to Deploy Ollama on a VPS →How to Deploy Open WebUI on a VPS →How to Deploy OpenHands on a VPS →How to Deploy OpenObserve on a VPS →How to Deploy OpenProject on a VPS →How to Deploy Outline on a VPS →How to Deploy Pangolin on a VPS →How to Deploy Paperless-ngx on a VPS →How to Deploy Passbolt on a VPS →How to Deploy Plane on a VPS →How to Deploy Plausible Analytics on a VPS →How to Deploy Pocket ID on a VPS →How to Deploy PocketBase on a VPS →How to Deploy Prometheus on a VPS →How to Deploy Psono on a VPS →How to Deploy Radarr on a VPS →How to Deploy Rocket.Chat on a VPS →How to Deploy SigNoz on a VPS →How to Deploy Sonarr on a VPS →How to Deploy Stalwart on a VPS →How to Deploy Stirling-PDF on a VPS →How to Deploy Supabase on a VPS →How to Deploy Synapse on a VPS →How to Deploy Taiga on a VPS →How to Deploy TeamPass on a VPS →How to Deploy Tinyauth on a VPS →How to Deploy Traefik on a VPS →How to Deploy Trilium on a VPS →How to Deploy Twenty CRM on a VPS →How to Deploy Umami on a VPS →How to Deploy Uptime Kuma on a VPS →How to Deploy Vaultwarden on a VPS →How to Deploy Vikunja on a VPS →How to Deploy wg-easy on a VPS →How to Deploy Wiki.js on a VPS →How to Deploy Zabbix on a VPS →How to Deploy Zulip on a VPS →Docker & Compose on Ubuntu 26.04 →Building AI Workflows with n8n →Install Open WebUI with Ollama →Adding AI-Powered Insights to Plausible Analytics →Building AI-Powered Apps with Supabase and pgvector →

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.