How to Install Zitadel on Ubuntu 24.04 VPS: Modern Open-Source Identity & Access Management
Zitadel is the fastest-growing open-source identity platform in the cloud-native ecosystem, and for good reason. It delivers everything teams used to stitch together -- OIDC, OAuth 2.0, SAML 2.0, SCIM, passkeys, multi-factor authentication, B2B multi-tenancy, and audit logs -- in a single Go binary that starts in under two seconds. This guide walks you through installing Zitadel on a fresh Ubuntu 24.04 VPS from first boot to a hardened, TLS-protected, backup-ready production deployment serving real applications.
Recommended VPS: This guide is tuned for the CloudCore Professional plan (6 vCPU, 12 GB RAM, 100 GB NVMe, EUR 19.99/month). That gives Zitadel and PostgreSQL enough headroom to serve tens of thousands of users with room for Traefik, backups, and monitoring agents.
Table of Contents
What is Zitadel?
Zitadel is a cloud-native identity and access management (IAM) platform built in Go and designed around event sourcing. It provides a single, cohesive system for authentication (who are you?), authorization (what can you do?), and user lifecycle management across B2C, B2B, and internal workforce scenarios. The full Zitadel feature set is documented at zitadel.com/docs.
Under the hood, Zitadel persists every change as an immutable event in PostgreSQL (or CockroachDB), which gives you a tamper-evident audit log for free and makes point-in-time replays trivial. On top of that event store, it projects read models for users, projects, applications, and grants. The public API surface is gRPC, with HTTP/JSON transcoding, and all the standard protocols layer on top: OpenID Connect 1.0 (the primary mechanism), OAuth 2.0, OAuth 2.1 PKCE, SAML 2.0 (both IdP and SP), SCIM 2.0 for provisioning, and LDAP directory bind for legacy apps.
What really sets Zitadel apart is how it treats multi-tenancy and B2B scenarios as first-class primitives. Every instance has organizations; every organization has its own users, domains, branding, login policies, and projects. You can invite external organizations to share access to a project -- ideal for SaaS vendors who need their customers' employees to sign in with their own corporate identities without building federation from scratch. Combined with granular roles, metadata, and actions, this makes Zitadel one of the few IAM systems designed from day one for modern SaaS business models.
Why Self-Host Zitadel Instead of Using Auth0 or Okta?
Managed identity services are excellent for getting started, but self-hosting Zitadel on your own VPS becomes compelling once your user count grows or your compliance posture tightens. Here are the concrete trade-offs:
- Flat-rate pricing regardless of MAU. Auth0, Okta, and Zitadel Cloud all price by monthly active users, external users, machine-to-machine tokens, or enterprise connections. At 10,000 MAU, most managed IAMs charge several hundred to several thousand USD per month. A single CloudCore Professional VPS at EUR 19.99/month handles the same workload for a flat fee that never scales with usage.
- Complete data sovereignty. User identities, password hashes, session data, and audit logs never leave infrastructure you control. This matters enormously for GDPR, HIPAA, SOC 2, and sectoral regulations (banking, healthcare, education) that require data residency guarantees.
- No vendor lock-in on authentication. Self-hosted OIDC means you own the issuer URL. If you ever change IAM systems, you only need to re-federate -- your applications are not tied to a specific vendor's proprietary extensions.
- Unlimited customization via actions. Zitadel actions let you run JavaScript on login, token issuance, user creation, or external IdP callbacks. You can enrich tokens with data from your billing system, block logins from specific countries, or auto-assign roles based on email domain. Managed tiers often gate this behind enterprise pricing.
- True audit trail via event sourcing. Every change to every entity is a durable event. You can answer "who reset this user's password at 03:14 UTC last Tuesday" instantly without scraping log aggregators.
- Zero per-request latency to your IdP. When Zitadel runs on the same VPS (or adjacent VPC) as your app, token validation and userinfo calls are sub-millisecond. Compare that to the 80-250ms round-trip typical of a managed IAM in a distant region.
- Apache 2.0 license, forever free. Fork it, modify it, embed it. No gotchas, no "community edition" feature gaps. The self-hosted build has feature parity with Zitadel Cloud.
Cost Comparison: Managed IAM vs. Self-Hosted Zitadel
| Scenario | Auth0 B2C Essentials | Okta Workforce | Zitadel Cloud | Self-Hosted Zitadel |
|---|---|---|---|---|
| 1,000 MAU | ~$35/mo | ~$2,000/mo | Free tier | EUR 19.99/mo |
| 10,000 MAU | ~$240/mo | ~$20,000/mo | ~$100/mo | EUR 19.99/mo |
| 100,000 MAU | ~$1,500/mo | Enterprise quote | Enterprise quote | EUR 19.99/mo |
| Data residency control | Region choice only | Region choice only | Region choice only | Full control |
| Passkeys included | Yes | Yes | Yes | Yes |
| Custom actions/hooks | Limited tier | Enterprise | All tiers | Unlimited |
| Audit log retention | 30 days (free) | 90 days | Unlimited | Unlimited (pg) |
Prerequisites
Before starting, you will need:
- Ubuntu 24.04 LTS VPS with root or sudo access
- SSH access to the server
- At least 4 GB RAM (12 GB recommended for production workloads)
- At least 40 GB free disk for Zitadel, PostgreSQL data, and backups
- A public domain name (we will use
id.example.comthroughout) - DNS control so you can create an A record pointing to the VPS
- Ports 80 and 443 open to the internet for Let's Encrypt and user traffic
Recommended Plan: CloudCore Professional>
The CloudCore Professional VPS provides:>
- 6 vCPU cores (for Zitadel + PostgreSQL + Traefik)
- 12 GB RAM (plenty for tens of thousands of MAU)
- 100 GB NVMe SSD (low-latency PostgreSQL I/O)
- Unmetered bandwidth
- EUR 19.99/month>
If you are running Docker on the same host for the first time, check our companion guide: How to Install Docker on Ubuntu 24.04.
Connect to your server:
ssh root@your-server-ipStep 1: Prepare the Ubuntu 24.04 VPS
Update all packages and install the baseline utilities you will need.
sudo apt update && sudo apt upgrade -y
sudo apt install -y curl ca-certificates gnupg lsb-release ufw fail2ban openssl jqCreate a non-root admin user (skip if you already have one):
sudo adduser admin
sudo usermod -aG sudo admin
sudo rsync --archive --chown=admin:admin ~/.ssh /home/adminSet the hostname and timezone:
sudo hostnamectl set-hostname zitadel.example.com
sudo timedatectl set-timezone UTCReboot to apply any kernel updates:
sudo rebootReconnect as the admin user after a minute:
ssh admin@your-server-ipStep 2: Install Docker and Docker Compose
Zitadel ships first-class Docker images, and Compose is the cleanest way to orchestrate it alongside PostgreSQL and Traefik.
Install Docker from the official Docker repository:
sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg sudo chmod a+r /etc/apt/keyrings/docker.gpgecho "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \ | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Add your user to the docker group so you can run Compose without sudo:
sudo usermod -aG docker $USER
newgrp dockerVerify both binaries are working:
docker --version
docker compose versionExpected output:
Docker version 27.4.0, build abcdef0
Docker Compose version v2.32.1If you need a deeper walkthrough (GPU runtime, log rotation, registry mirrors), see How to Install Docker on Ubuntu 24.04.
Step 3: Configure DNS and Firewall
Zitadel uses the value of ZITADEL_EXTERNALDOMAIN to generate issuer URLs, cookies, and OIDC discovery documents. It must match a real DNS name that resolves to your VPS, or OIDC flows will break.
Create an A record at your DNS provider:
Type Name Value TTL
A id.example.com <your-vps-ip> 300Verify it resolves (wait 1-2 minutes after creation):
dig +short id.example.comOpen the required ports with UFW:
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
sudo ufw status verbosePort 80 is required for the Let's Encrypt HTTP-01 challenge; port 443 serves all user and application traffic. The Zitadel container itself never binds to the public internet -- Traefik fronts it.
Step 4: Generate the Master Key and Secrets
Zitadel encrypts sensitive data (client secrets, API keys, SMTP credentials) with a master key you control. If you lose this key, encrypted data becomes unrecoverable, so treat it with the same care as a root CA private key.
Create the project directory:
sudo mkdir -p /opt/zitadel
sudo chown -R $USER:$USER /opt/zitadel
cd /opt/zitadel
mkdir -p backups traefik letsencryptGenerate a 32-character master key and a strong PostgreSQL password:
cat > .env <<EOF ZITADEL_EXTERNALDOMAIN=id.example.com ZITADEL_EXTERNALPORT=443 ZITADEL_EXTERNALSECURE=true ZITADEL_MASTERKEY=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 32) POSTGRES_PASSWORD=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32) POSTGRES_ADMIN_PASSWORD=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32) [email protected] EOF
chmod 600 .env
Record the master key somewhere safe (a password manager, a Vault instance, or printed and locked in a safe). Losing it means every encrypted secret in the database becomes gibberish.
Step 5: Write the Docker Compose Stack
This Compose file wires up PostgreSQL 16, Zitadel, and Traefik with automatic Let's Encrypt certificates. Create /opt/zitadel/docker-compose.yml:
services: traefik: image: traefik:v3.2 container_name: traefik restart: unless-stopped command: - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" - "--entrypoints.web.http.redirections.entrypoint.to=websecure" - "--entrypoints.web.http.redirections.entrypoint.scheme=https" - "--entrypoints.websecure.address=:443" - "--certificatesresolvers.le.acme.email=${ACME_EMAIL}" - "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json" - "--certificatesresolvers.le.acme.httpchallenge=true" - "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web" - "--log.level=INFO" - "--accesslog=true" ports: - "80:80" - "443:443" volumes: - ./letsencrypt:/letsencrypt - /var/run/docker.sock:/var/run/docker.sock:ro networks: - webpostgres: image: postgres:16-alpine container_name: zitadel-postgres restart: unless-stopped environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD} POSTGRES_DB: zitadel PGDATA: /var/lib/postgresql/data/pgdata volumes: - pg-data:/var/lib/postgresql/data networks: - internal healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s timeout: 5s retries: 5 command: - "postgres" - "-c" - "shared_buffers=512MB" - "-c" - "work_mem=16MB" - "-c" - "max_connections=200"
zitadel: image: ghcr.io/zitadel/zitadel:latest container_name: zitadel restart: unless-stopped command: 'start-from-init --masterkey "${ZITADEL_MASTERKEY}" --tlsMode external' environment: ZITADEL_EXTERNALDOMAIN: ${ZITADEL_EXTERNALDOMAIN} ZITADEL_EXTERNALPORT: ${ZITADEL_EXTERNALPORT} ZITADEL_EXTERNALSECURE: ${ZITADEL_EXTERNALSECURE} ZITADEL_DATABASE_POSTGRES_HOST: postgres ZITADEL_DATABASE_POSTGRES_PORT: 5432 ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${POSTGRES_PASSWORD} ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${POSTGRES_ADMIN_PASSWORD} ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable ZITADEL_FIRSTINSTANCE_ORG_HUMAN_EMAIL_ADDRESS: ${ACME_EMAIL} ZITADEL_FIRSTINSTANCE_ORG_HUMAN_EMAIL_VERIFIED: "true" depends_on: postgres: condition: service_healthy networks: - internal - web labels: - "traefik.enable=true" - "traefik.http.routers.zitadel.rule=Host(
${ZITADEL_EXTERNALDOMAIN})" - "traefik.http.routers.zitadel.entrypoints=websecure" - "traefik.http.routers.zitadel.tls=true" - "traefik.http.routers.zitadel.tls.certresolver=le" - "traefik.http.services.zitadel.loadbalancer.server.port=8080" - "traefik.http.services.zitadel.loadbalancer.server.scheme=h2c" - "traefik.docker.network=zitadel_web"volumes: pg-data:
networks: web: internal:
A few important notes:
--tlsMode externaltells Zitadel that TLS is terminated by an upstream proxy (Traefik). Zitadel speaks plain HTTP/2 (h2c) inside the Docker network.- The
scheme=h2clabel is mandatory; Zitadel uses HTTP/2 cleartext internally for its gRPC transcoding, and HTTP/1.1 will cause admin UI errors. - PostgreSQL is tuned lightly (512 MB shared buffers) -- adjust upward if you have more RAM.
start-from-initbootstraps the database schema on first run and then transitions to normal start behavior.
Step 6: First Boot and Initial Admin Login
Launch the stack:
cd /opt/zitadel
docker compose up -dFollow the logs until you see the initialization complete:
docker compose logs -f zitadelYou are looking for lines similar to:
info setup completed
info server is listening on [::]:8080The initial admin user is created on first boot. Retrieve the credentials from the logs:
docker compose logs zitadel | grep -E "Initial|password"By default, Zitadel creates a user named zitadel-admin@zitadel.<your-domain> with a randomly generated password printed to the logs. Alternatively, if you set ZITADEL_FIRSTINSTANCE_ORG_HUMAN_EMAIL_ADDRESS in the Compose file (as we did), the admin email will be [email protected].
Open your browser and navigate to:
https://id.example.com/ui/consoleTraefik will issue a Let's Encrypt certificate on first request (this takes 15-30 seconds). Sign in with:
- Username:
[email protected] - Password: the value from the logs
Step 7: Create Organizations, Projects, and OIDC Apps
Zitadel's conceptual model is:
Instance -> Organizations -> Projects -> Applications + RolesCreate an Organization
In the Zitadel console, click the organization switcher (top-left), then Create New Organization. Name it after your tenant (for example, Acme Corp). Each organization has its own users, domains, and login policy. For a B2B SaaS, you create one organization per customer.
Create a Project
Inside the organization, go to Projects -> New. Name it (for example, Acme Web App). A project is a container for applications and roles. Typical settings to enable:
- Assert Roles on Authentication -- embeds the user's roles in the ID token claims (useful for frontend apps).
- Check authorization on authentication -- blocks login if the user has no grants to this project.
Register an OIDC Application
Inside the project, click New Application, pick Web, and choose a template:
- PKCE for single-page apps (React, Vue, Svelte) and native mobile apps
- Code for traditional server-rendered apps (Django, Rails, Laravel, Next.js)
- Post for legacy flows (rarely needed)
https://app.example.com/auth/callback
https://app.example.com/silent-renewAnd post-logout redirect URIs:
https://app.example.com/logged-outAfter creation, Zitadel displays the Client ID and (for confidential clients) the Client Secret. Record these in your application's environment variables. The OIDC discovery document lives at:
https://id.example.com/.well-known/openid-configurationYour application's OIDC library (oidc-client-ts, next-auth, Authlib, etc.) only needs that single URL plus the client ID and secret to establish the full protocol.
Define Roles and Grants
Under Project -> Roles, create roles like admin, editor, viewer. Then under Users -> Authorizations (or the project's Authorizations tab), grant each user one or more roles. On the next login, those role keys appear in the ID token under the urn:zitadel:iam:org:project:roles claim.
Step 8: Enable Passkeys, MFA, and Passwordless Login
Modern IAM should default to phishing-resistant authentication. Zitadel ships passkey support (FIDO2/WebAuthn) out of the box.
Navigate to your organization's Default Settings -> Login Behavior and Security. Toggle:
- Passwordless Authentication Allowed -> ON
- Force MFA -> ON
- Second Factors: OTP (TOTP) -> Enabled
- Second Factors: WebAuthn -> Enabled
- Multi-Factors: WebAuthn (passwordless) -> Enabled
- Minimum length: 12
- Require uppercase, lowercase, digit, symbol: ON
- Lockout policy: 5 failed attempts -> 15-minute lockout
For existing users, you can also trigger a forced passkey registration by sending an Init Passkey notification from the user's admin page.
Step 9: Configure External Identity Providers
Zitadel can federate with external IdPs so users sign in with Google, GitHub, Microsoft Entra, or any OIDC/SAML provider. This is how you offer "Sign in with Google" to your B2C users or connect to a customer's corporate SSO in B2B deployments.
Navigate to Default Settings -> Identity Providers -> New (or set it at the organization level for tenant-specific federation).
Add Google
https://id.example.com/ui/login/login/externalidp/callbackemail, profile, openid scopes.Add a Generic OIDC Provider (Microsoft Entra, Okta, etc.)
Pick the OpenID Connect template and supply:
- Name:
Entra ID - Issuer:
https://login.microsoftonline.com/<tenant-id>/v2.0 - Client ID and Secret from the Entra app registration
- Scopes:
openid profile email
SAML 2.0 for Legacy Enterprise Customers
For customers on older corporate SSO, Zitadel can act as both SAML IdP and SP. Use New -> SAML and paste the metadata XML from the partner's IdP. Zitadel exposes its own SAML metadata at:
https://id.example.com/saml/v2/metadataStep 10: Create Machine Users for API Access
Applications that need to call the Zitadel Management API (to programmatically create users, sync groups from HR, update metadata) use machine users. These are non-human accounts authenticating with a private key JWT.
api-bot.IAM_OWNER (full admin) or a narrower role like ORG_USER_MANAGER depending on blast radius.Using the key from your application:
# Using zitadel-cli or any JWT profile OAuth client
export ZITADEL_KEY_FILE=/secure/path/api-bot-key.jsonObtain an access token
access_token=$(zitadel-tools key2jwt --key-file $ZITADEL_KEY_FILE \
--audience https://id.example.com \
| xargs -I{} curl -s https://id.example.com/oauth/v2/token \
-d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
-d "assertion={}" \
-d "scope=openid urn:zitadel:iam:org:project:id:zitadel:aud" \
| jq -r .access_token)Call the Management API
curl -H "Authorization: Bearer $access_token" \
https://id.example.com/management/v1/users/_search \
-d '{"queries":[]}'Machine users are the secure alternative to static API keys -- tokens are short-lived (5 minutes to 12 hours) and derived from a signed JWT that never traverses the network.
Step 11: Write Actions (JavaScript Hooks)
Actions run JavaScript on specific lifecycle events: user creation, login, token issuance, external IdP callbacks, and more. They are Zitadel's equivalent of Auth0 Rules or Keycloak Authenticator SPIs, without the pain of writing and deploying custom extensions.
Enable the feature flag first (one-time instance setting):
docker compose exec zitadel \
/app/zitadel system set-features --actions=trueIn the console, navigate to Actions -> Scripts -> New. A simple example that attaches a tenant_id claim to every ID token based on the user's organization metadata:
/** * Flow: Complement Token * Trigger: Pre Userinfo creation */ function addTenantClaim(ctx, api) { const orgId = ctx.v1.getUser().getResourceOwner(); api.v1.claims.setClaim("tenant_id", orgId);
const metadata = ctx.v1.user.metadata; if (metadata) { metadata.forEach(function (entry) { if (entry.key === "plan_tier") { api.v1.claims.setClaim("plan", entry.value); } }); } }
Save the script, then attach it to a flow under Actions -> Flows. For this example, pick Complement Token as the trigger type and Pre Userinfo creation as the trigger event. On the next login, inspect the ID token at jwt.io and you will see the custom claims.
Other common action patterns:
- Block logins from sanctioned countries by checking
ctx.v1.claims.countryand throwing. - Auto-assign roles by email domain pattern (for example, all
@partner.comusers getexternal-partner). - Call an external webhook (via
api.v1.http.fetch) to notify your billing system of signups. - Enforce IP allowlists for admin users during MFA selection.
Step 12: TLS with Traefik (or Nginx Alternative)
The Compose stack already uses Traefik with Let's Encrypt. The HTTP challenge solves the cert on first request -- no manual certbot dance required. To verify the certificate is healthy:
curl -sI https://id.example.com | head -5Expected:
HTTP/2 200
content-type: text/html; charset=utf-8
...Inspect the issued cert:
echo | openssl s_client -connect id.example.com:443 -servername id.example.com 2>/dev/null \
| openssl x509 -noout -issuer -datesNginx Alternative
If you prefer Nginx (for example, to centralize SSL with an existing reverse proxy), remove the Traefik service from Compose and expose Zitadel on 127.0.0.1:8080 instead. Use this Nginx config at /etc/nginx/sites-available/zitadel:
server { listen 443 ssl http2; server_name id.example.com;ssl_certificate /etc/letsencrypt/live/id.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/id.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5;
# gRPC / HTTP/2 required for admin API grpc_read_timeout 300s; client_max_body_size 20m;
location / { grpc_pass grpc://127.0.0.1:8080; grpc_set_header Host $host; grpc_set_header X-Forwarded-For $proxy_add_x_forwarded_for; grpc_set_header X-Forwarded-Proto https; } }
server { listen 80; server_name id.example.com; return 301 https://$host$request_uri; }
The important detail is grpc_pass -- Zitadel speaks gRPC, not HTTP/1.1, for its API. Using proxy_pass here will break the admin console and the Management API.
Step 13: Backups and Disaster Recovery
Zitadel's entire state lives in PostgreSQL, so backups reduce to PostgreSQL backups plus the .env file containing the master key.
Create the backup script at /opt/zitadel/backups/backup.sh:
#!/usr/bin/env bash set -euo pipefailTIMESTAMP=$(date +%Y%m%d-%H%M%S) BACKUP_DIR=/opt/zitadel/backups RETENTION_DAYS=14
cd /opt/zitadel
Dump the database
docker compose exec -T postgres \ pg_dump -U postgres -d zitadel --format=custom \ > "$BACKUP_DIR/zitadel-$TIMESTAMP.dump"Also snapshot the .env (contains the master key!)
cp .env "$BACKUP_DIR/env-$TIMESTAMP.bak"Prune old backups
find "$BACKUP_DIR" -name "zitadel-*.dump" -mtime +$RETENTION_DAYS -delete find "$BACKUP_DIR" -name "env-*.bak" -mtime +$RETENTION_DAYS -deleteOptional: ship off-site to S3-compatible storage
aws s3 cp "$BACKUP_DIR/zitadel-$TIMESTAMP.dump" s3://my-backups/zitadel/
Make it executable and schedule it nightly:
chmod +x /opt/zitadel/backups/backup.sh
(crontab -l 2>/dev/null; echo "0 3 * /opt/zitadel/backups/backup.sh >> /var/log/zitadel-backup.log 2>&1") | crontab -Restore Procedure
To restore onto a new server:
# Stop Zitadel (keep PostgreSQL running)
docker compose stop zitadelDrop and recreate the database
docker compose exec postgres \
psql -U postgres -c "DROP DATABASE zitadel; CREATE DATABASE zitadel;"Restore from dump
cat /opt/zitadel/backups/zitadel-20260415-030000.dump \
| docker compose exec -T postgres \
pg_restore -U postgres -d zitadel --clean --if-existsBring Zitadel back up (the master key in .env must match!)
docker compose up -d zitadelCritical: The master key in.envon the restored server must be identical to the one used when the backup was taken. Otherwise every encrypted secret (client secrets, external IdP credentials, SMTP passwords) becomes unrecoverable. Back up the.envfile to a separate secure location -- not on the same disk as the database dumps.
For off-site replication, pipe the dump to any S3-compatible object storage (Backblaze B2, Wasabi, MinIO, or the built-in S3 on CloudCore Professional object storage add-ons).
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Admin console shows "Bad Gateway" or gRPC errors | Traefik not configured for h2c | Ensure the traefik.http.services.zitadel.loadbalancer.server.scheme=h2c label is present |
| Login loops back to the login page | ZITADEL_EXTERNALDOMAIN mismatch with browser URL | The domain in the browser must exactly match ZITADEL_EXTERNALDOMAIN (no www vs non-www drift) |
| Certificate does not issue | Port 80 blocked or DNS not propagated | Check ufw status, wait for DNS TTL, inspect Traefik logs: docker compose logs traefik |
failed to initialize instance: context deadline exceeded | PostgreSQL not ready before Zitadel starts | The depends_on: condition: service_healthy handles this; if it still races, increase the retry count in the healthcheck |
| "invalid issuer" in OIDC client | Client configured with wrong issuer URL | Must be https://id.example.com (no trailing slash, no path) |
| High RAM usage on PostgreSQL | shared_buffers too large for VPS | Reduce to shared_buffers=256MB in the Compose command section |
| Actions not triggering | Feature flag not enabled | Run /app/zitadel system set-features --actions=true inside the container |
| Lost the master key | Disaster scenario | No recovery possible. Restore from a backup taken before the loss, or spin up fresh instance and re-register all apps. |
Viewing Logs
# Stream all service logs
docker compose logs -fZitadel only
docker compose logs -f zitadelLast 200 lines of PostgreSQL
docker compose logs --tail=200 postgresFAQ
What is the difference between Zitadel and Keycloak?
Both are open-source IAM systems, but their design philosophies differ significantly. Keycloak is a mature Java/Quarkus application inherited from Red Hat, with an enormous feature surface and deep SAML tooling. It is the right choice when you need every knob SAML and WS-Federation have ever invented. Zitadel is a Go application built around event sourcing and designed for cloud-native multi-tenancy. It starts in 1-2 seconds (vs 30+ for Keycloak), uses roughly a quarter of the RAM, and treats B2B organizations and passkeys as core primitives rather than add-ons. If you are building a modern SaaS and want fast startup, clean APIs, and turnkey multi-tenancy, Zitadel is usually the better fit. If you need deep SAML customization or must integrate with legacy enterprise directory environments, Keycloak may be preferable. Our Keycloak install guide and Authentik install guide cover those alternatives.
Do I need CockroachDB or will PostgreSQL work?
PostgreSQL 14+ is fully supported and is the recommended storage backend for single-node Zitadel deployments. CockroachDB is only necessary when you need horizontally scaled, multi-region strong consistency -- for example, a global SaaS with write traffic in several continents. For single-VPS installs, PostgreSQL 16 is simpler, has better tooling, and performs excellently. All official Zitadel documentation and migrations are tested against both databases.
How many users can a single Zitadel VPS handle?
A Zitadel node on a CloudCore Professional (6 vCPU, 12 GB RAM, NVMe) comfortably serves tens of thousands of monthly active users and millions of tokens per day. The primary bottleneck is PostgreSQL I/O -- event sourcing writes many small events per login -- so NVMe storage is essential. For a typical B2B SaaS with 50,000 MAU, you will see PostgreSQL use roughly 2-4 GB RAM, Zitadel 1-2 GB, and plenty of headroom left. If you cross 100K MAU or 50 logins per second, consider splitting PostgreSQL to a dedicated VPS with read replicas.
Can I migrate from Auth0 or Okta to self-hosted Zitadel?
Yes, and this is a common motivation for self-hosting. Zitadel supports three import paths: (1) Bulk user import via the Management API, accepting email, hashed passwords (bcrypt/argon2id), metadata, and role grants; (2) SCIM 2.0 provisioning from any IdP that speaks SCIM -- so you can keep your old IAM as a source of truth during migration; (3) Just-in-time federation where Zitadel sits in front of Auth0/Okta temporarily and copies users on first login. Applications only need to update their OIDC issuer URL and client credentials -- the protocol itself remains identical.
Is Zitadel free for commercial use?
Yes. Zitadel is released under the Apache 2.0 license and is free to self-host for any use case, including commercial B2B and B2C SaaS. The company behind Zitadel sells a managed hosted tier (Zitadel Cloud) and enterprise support contracts, but the self-hosted Community Edition has full feature parity -- no gated enterprise features, no user caps.
How do passkeys work in Zitadel?
Zitadel implements WebAuthn/FIDO2 to the full spec, including platform authenticators (Touch ID, Windows Hello, Android biometric) and roaming authenticators (YubiKey, Google Titan). When a user registers a passkey, Zitadel stores only the public key in the database; the private key never leaves the user's device. On subsequent logins, the browser performs a signed challenge-response that is phishing-resistant by design -- a cloned login page cannot capture the passkey because the WebAuthn protocol binds the assertion to the exact origin. For resident keys (discoverable credentials), users skip the username step entirely: the device presents the list of available passkeys for id.example.com, they pick one, and they are in.
Next Steps
Now that Zitadel is running in production, these are high-value next steps:
- Enable custom branding -- Upload your logo, favicon, and color theme under Default Settings -> Branding. Each organization can override branding for tenant-specific login pages, crucial for white-label B2B SaaS.
- Wire up SMTP for email notifications -- Configure SMTP Settings with your transactional email provider (Postmark, Amazon SES, Resend) so verification emails, password resets, and MFA prompts actually deliver. Without this, self-service flows stall.
- Ship audit logs to SIEM -- Zitadel's event store is queryable via gRPC; export events to Loki, Elastic, or Splunk with a small Go sidecar. This gives compliance auditors a clean trail for SOC 2 and ISO 27001.
- Add monitoring with Prometheus -- Zitadel exposes Prometheus metrics at
/debug/metrics. Scrape them with a Prometheus instance and build Grafana dashboards for login success rate, token issuance rate, and PostgreSQL connection pool saturation.
- Automate with Terraform -- The Zitadel Terraform provider lets you define organizations, projects, applications, and actions as code. Essential for reproducible multi-environment (dev/staging/prod) deployments.
- Connect your first app -- Point your Next.js, Rails, or Django app at the OIDC discovery URL and watch user signups flow in. Zitadel's SDK library at zitadel.com/docs/sdk-examples covers the major frameworks.
Get Zitadel-Ready Infrastructure>
Run Zitadel (plus PostgreSQL, Traefik, and your apps) comfortably on our CloudCore Professional VPS:>
- 6 vCPU cores, 12 GB RAM, 100 GB NVMe
- Unmetered bandwidth, EU and US data centers
- Free snapshots and off-site backups
- EUR 19.99/month -- flat, no per-user fees>
Deploy Your Zitadel VPS Now and have a production identity provider live in under an hour.