Skip to main contentSkip to navigation
[email protected]
Client AreaSupport
Hosting Mammoth
HostingMammothYour Data, Our Responsibility
Home
Solutions
Hosting Services
Store
Pricing
About
Blog
API
Contact

Stay Ahead of the Curve

Get the latest insights on cybersecurity, AI innovations, and enterprise data solutions delivered to your inbox.

Hosting Mammoth
HostingMammothEnterprise Solutions

Enterprise-grade data solutions. Hosting, recovery, cybersecurity, and AI-powered services for businesses worldwide.

[email protected]
Sun - Fri, 9:00am - 5:00pm

Services

  • Cloud Hosting
  • Data Recovery
  • Cybersecurity
  • Legal Support
  • MSP Services
  • Web Development
  • AI Services
  • Free Server Migration

Hosting

  • VPS Hosting (NVMe SSD)
  • VDS Hosting (NVMe)
  • Storage VPS (High SSD)
  • GPU Servers
  • Managed Services
  • Cloud Firewall
  • Load Balancer
  • One-Click Apps
  • n8n Hosting
  • Object Storage
  • FAQ

Company

  • Store
  • Pricing
  • About Us
  • Locations
  • Blog
  • Testimonials
  • Contact
  • Affiliate Program
  • White-Label
  • Terms of Service
  • Privacy Policy
  • Browser Cookies
  • SLA

Support

  • Client Area
  • Submit Ticket
  • Knowledge Base
  • Server Status
  • API Documentation

© 2026 Hosting Mammoth. All rights reserved.

Knowledge Base
Getting StartedAccount ManagementVPS HostingGPU ServersStorage VPSCloud FirewallLoad BalancerServer ManagementBilling & PaymentsSupport & TicketsAffiliate ProgramReseller ProgramMarketplace & Appsn8n HostingManaged ServicesServer MigrationAPI & DevelopersSecurityTroubleshootingGlossaryInstall Guides
  1. Home
  2. /
  3. Support
  4. /
  5. Install Guides
  6. /
  7. How To Install Zitadel Ubuntu
GUIDEInstall Guides

How to Install Zitadel on Ubuntu 24.04 VPS: Modern Open-Source Identity & Access Management

28 min read

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?
  • Why Self-Host Zitadel Instead of Using Auth0 or Okta?
  • Prerequisites
  • Step 1: Prepare the Ubuntu 24.04 VPS
  • Step 2: Install Docker and Docker Compose
  • Step 3: Configure DNS and Firewall
  • Step 4: Generate the Master Key and Secrets
  • Step 5: Write the Docker Compose Stack
  • Step 6: First Boot and Initial Admin Login
  • Step 7: Create Organizations, Projects, and OIDC Apps
  • Step 8: Enable Passkeys, MFA, and Passwordless Login
  • Step 9: Configure External Identity Providers
  • Step 10: Create Machine Users for API Access
  • Step 11: Write Actions (JavaScript Hooks)
  • Step 12: TLS with Traefik (or Nginx Alternative)
  • Step 13: Backups and Disaster Recovery
  • Troubleshooting
  • FAQ
  • Next Steps
  • 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

    ScenarioAuth0 B2C EssentialsOkta WorkforceZitadel CloudSelf-Hosted Zitadel
    1,000 MAU~$35/mo~$2,000/moFree tierEUR 19.99/mo
    10,000 MAU~$240/mo~$20,000/mo~$100/moEUR 19.99/mo
    100,000 MAU~$1,500/moEnterprise quoteEnterprise quoteEUR 19.99/mo
    Data residency controlRegion choice onlyRegion choice onlyRegion choice onlyFull control
    Passkeys includedYesYesYesYes
    Custom actions/hooksLimited tierEnterpriseAll tiersUnlimited
    Audit log retention30 days (free)90 daysUnlimitedUnlimited (pg)
    For any SaaS planning to cross 5,000 MAU within a year, self-hosted Zitadel pays for itself within weeks.

    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.com throughout)
    • 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:

    bash
    ssh root@your-server-ip

    Step 1: Prepare the Ubuntu 24.04 VPS

    Update all packages and install the baseline utilities you will need.

    bash
    sudo apt update && sudo apt upgrade -y
    sudo apt install -y curl ca-certificates gnupg lsb-release ufw fail2ban openssl jq

    Create a non-root admin user (skip if you already have one):

    bash
    sudo adduser admin
    sudo usermod -aG sudo admin
    sudo rsync --archive --chown=admin:admin ~/.ssh /home/admin

    Set the hostname and timezone:

    bash
    sudo hostnamectl set-hostname zitadel.example.com
    sudo timedatectl set-timezone UTC

    Reboot to apply any kernel updates:

    bash
    sudo reboot

    Reconnect as the admin user after a minute:

    bash
    ssh admin@your-server-ip

    Step 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:

    bash
    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.gpg

    echo "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:

    bash
    sudo usermod -aG docker $USER
    newgrp docker

    Verify both binaries are working:

    bash
    docker --version
    docker compose version

    Expected output:

    text
    Docker version 27.4.0, build abcdef0
    Docker Compose version v2.32.1

    If 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:

    text
    Type   Name              Value              TTL
    A      id.example.com    <your-vps-ip>      300

    Verify it resolves (wait 1-2 minutes after creation):

    bash
    dig +short id.example.com

    Open the required ports with UFW:

    bash
    sudo ufw allow 22/tcp
    sudo ufw allow 80/tcp
    sudo ufw allow 443/tcp
    sudo ufw --force enable
    sudo ufw status verbose

    Port 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:

    bash
    sudo mkdir -p /opt/zitadel
    sudo chown -R $USER:$USER /opt/zitadel
    cd /opt/zitadel
    mkdir -p backups traefik letsencrypt

    Generate a 32-character master key and a strong PostgreSQL password:

    bash
    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:

    yaml
    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:
          - web

    postgres: 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 external tells Zitadel that TLS is terminated by an upstream proxy (Traefik). Zitadel speaks plain HTTP/2 (h2c) inside the Docker network.
    • The scheme=h2c label 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-init bootstraps the database schema on first run and then transitions to normal start behavior.

    Step 6: First Boot and Initial Admin Login

    Launch the stack:

    bash
    cd /opt/zitadel
    docker compose up -d

    Follow the logs until you see the initialization complete:

    bash
    docker compose logs -f zitadel

    You are looking for lines similar to:

    text
    info setup completed
    info server is listening on [::]:8080

    The initial admin user is created on first boot. Retrieve the credentials from the logs:

    bash
    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:

    text
    https://id.example.com/ui/console

    Traefik 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
    You will be forced to change the password on first login. Set a strong one and store it in your password manager.

    Step 7: Create Organizations, Projects, and OIDC Apps

    Zitadel's conceptual model is:

    text
    Instance  ->  Organizations  ->  Projects  ->  Applications + Roles

    Create 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)
    Fill in the redirect URIs:

    text
    https://app.example.com/auth/callback
    https://app.example.com/silent-renew

    And post-logout redirect URIs:

    text
    https://app.example.com/logged-out

    After 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:

    text
    https://id.example.com/.well-known/openid-configuration

    Your 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
    Set the password complexity policy under Password Policy:

    • Minimum length: 12
    • Require uppercase, lowercase, digit, symbol: ON
    • Lockout policy: 5 failed attempts -> 15-minute lockout
    On the next login, users will see the option to Register a Passkey or Add Authenticator App. Once registered, passkey becomes the default method and passwords can be phased out.

    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

  • In Google Cloud Console, create an OAuth 2.0 Client ID (type: Web application).
  • Authorized redirect URI: https://id.example.com/ui/login/login/externalidp/callback
  • Back in Zitadel, pick the Google template, paste the Client ID + Client Secret, and tick the email, profile, openid scopes.
  • Under Login Policy, add Google to the list of allowed IdPs.
  • 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
    Zitadel auto-discovers the authorization and token endpoints from the issuer's discovery document. On the next login, users see a "Sign in with Entra ID" button alongside the password form.

    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:

    text
    https://id.example.com/saml/v2/metadata

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

  • In the organization's Users -> Service Users -> New, create a user like api-bot.
  • Click the new user, go to Keys -> New Key, choose JSON, and set a sensible expiration (for example, 365 days).
  • Download the JSON file. It contains the private key and metadata needed for the JWT profile grant. This file is shown only once -- store it in your secret manager.
  • Under Authorizations, grant the machine user IAM_OWNER (full admin) or a narrower role like ORG_USER_MANAGER depending on blast radius.
  • Using the key from your application:

    bash
    # Using zitadel-cli or any JWT profile OAuth client
    export ZITADEL_KEY_FILE=/secure/path/api-bot-key.json

    Obtain 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):

    bash
    docker compose exec zitadel \
      /app/zitadel system set-features --actions=true

    In 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:

    javascript
    /**
     * 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.country and throwing.
    • Auto-assign roles by email domain pattern (for example, all @partner.com users get external-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:

    bash
    curl -sI https://id.example.com | head -5

    Expected:

    text
    HTTP/2 200
    content-type: text/html; charset=utf-8
    ...

    Inspect the issued cert:

    bash
    echo | openssl s_client -connect id.example.com:443 -servername id.example.com 2>/dev/null \
      | openssl x509 -noout -issuer -dates

    Nginx 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:

    nginx
    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:

    bash
    #!/usr/bin/env bash
    set -euo pipefail

    TIMESTAMP=$(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 -delete

    Optional: 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:

    bash
    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:

    bash
    # Stop Zitadel (keep PostgreSQL running)
    docker compose stop zitadel

    Drop 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-exists

    Bring Zitadel back up (the master key in .env must match!)

    docker compose up -d zitadel
    Critical: The master key in .env on 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 .env file 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

    ProblemCauseSolution
    Admin console shows "Bad Gateway" or gRPC errorsTraefik not configured for h2cEnsure the traefik.http.services.zitadel.loadbalancer.server.scheme=h2c label is present
    Login loops back to the login pageZITADEL_EXTERNALDOMAIN mismatch with browser URLThe domain in the browser must exactly match ZITADEL_EXTERNALDOMAIN (no www vs non-www drift)
    Certificate does not issuePort 80 blocked or DNS not propagatedCheck ufw status, wait for DNS TTL, inspect Traefik logs: docker compose logs traefik
    failed to initialize instance: context deadline exceededPostgreSQL not ready before Zitadel startsThe depends_on: condition: service_healthy handles this; if it still races, increase the retry count in the healthcheck
    "invalid issuer" in OIDC clientClient configured with wrong issuer URLMust be https://id.example.com (no trailing slash, no path)
    High RAM usage on PostgreSQLshared_buffers too large for VPSReduce to shared_buffers=256MB in the Compose command section
    Actions not triggeringFeature flag not enabledRun /app/zitadel system set-features --actions=true inside the container
    Lost the master keyDisaster scenarioNo recovery possible. Restore from a backup taken before the loss, or spin up fresh instance and re-register all apps.

    Viewing Logs

    bash
    # Stream all service logs
    docker compose logs -f

    Zitadel only

    docker compose logs -f zitadel

    Last 200 lines of PostgreSQL

    docker compose logs --tail=200 postgres

    FAQ

    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.

    Was this article helpful?

    ← Back to Install GuidesBrowse all categories →

    Still have questions?

    Contact Support →Submit a Ticket