IAM / OIDC
    Docker Compose

    Deploy Zitadel Identity and Access Management on a VPS

    Self-host Zitadel on a RamNode VPS — OIDC, OAuth 2, SAML, and passkeys via the official Docker Compose stack with Traefik and Let's Encrypt.

    Zitadel is a modern, open source identity and access management platform built in Go. It gives you OIDC, OAuth 2, SAML, passkeys, multi factor authentication, and full multi tenancy, all behind a clean API first design and a polished console. People often reach for it as a lighter, easier to operate alternative to Keycloak. Its event sourced core means every change is stored as an immutable event, giving you a complete audit trail of every identity operation.

    This guide deploys a production minded Zitadel instance with PostgreSQL on a RamNode VPS using the project's official Docker Compose setup, with automatic TLS via the bundled Let's Encrypt overlay.

    Architecture in brief

    Zitadel v4 is a two container application: a Go based core API and a Next.js login UI, fronted by Traefik and backed by PostgreSQL. The login UI must share a network namespace with the core service, which the official compose files handle for you. PostgreSQL is the only supported database; CockroachDB support has been removed.

    Sizing and a candid resource warning

    Zitadel performs bcrypt and argon2 password hashing on login, which is CPU intensive under concurrent traffic. For a real production instance the project recommends at least 4 CPU cores to handle login storms gracefully.

    • Evaluation or low traffic internal use: a RamNode VPS with 2 vCPUs and 4 GB RAM works.
    • Production with real user load: choose 4 vCPUs and 8 GB RAM.

    Plan for the higher tier if this will actually authenticate users.

    Prerequisites

    • A RamNode KVM VPS running Ubuntu 24.04 LTS, sized per above.
    • Root or sudo access.
    • A domain pointed at the VPS, for example auth.example.com. Zitadel relies on domain based routing; the OIDC and SAML flows do not work reliably on a bare IP.
    • Ports 80 and 443 open.

    Step 1: Install Docker

    shell
    sudo apt update && sudo apt upgrade -y
    curl -fsSL https://get.docker.com | sudo sh
    sudo usermod -aG docker $USER

    Log out and back in, then confirm Docker Engine 24 or newer with the Compose plugin:

    shell
    docker version
    docker compose version

    Step 2: Fetch the official compose files

    shell
    mkdir ~/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
    cp .env.example .env

    The base stack runs Traefik as the reverse proxy in front of the Zitadel API and login UI, with PostgreSQL behind them. Traefik handles all routing, including gRPC over HTTP/2, with no extra configuration.

    Step 3: Generate secrets

    The master key encrypts sensitive data at rest and must be exactly 32 characters. Once Zitadel is initialized with it, it cannot be changed without losing access to encrypted data, so generate it now and store it somewhere safe:

    shell
    ZITADEL_MASTERKEY=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)
    echo "ZITADEL_MASTERKEY=$ZITADEL_MASTERKEY" >> .env
    
    # Strong database passwords
    echo "POSTGRES_ADMIN_PASSWORD=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)" >> .env
    echo "POSTGRES_ZITADEL_PASSWORD=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)" >> .env

    Record the master key in your password manager before moving on. Losing it means losing the data it protects.

    Step 4: Set your domain

    Edit .env and set your real external domain. The exact variable names are in the example file, but the value you care about is the external domain, for example auth.example.com. For the Let's Encrypt overlay, also set the contact email so you receive certificate expiry notices:

    shell
    echo "LETSENCRYPT_EMAIL=admin@example.com" >> .env

    Point the DNS A record for auth.example.com at your VPS before the next step, because the certificate issuance depends on it resolving.

    Step 5: Start with the Let's Encrypt overlay

    Download the overlay and bring the stack up:

    shell
    curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.mode-letsencrypt.yml
    
    docker compose --env-file .env \
      -f docker-compose.yml \
      -f docker-compose.mode-letsencrypt.yml \
      up -d --wait

    The --wait flag holds until the containers report healthy. The first start runs database initialization and setup phases automatically before the server begins listening.

    Step 6: First login

    Once the stack is healthy, browse to your domain and open the console:

    shell
    https://auth.example.com/ui/console

    The default first instance admin is created during initialization. On a fresh deployment the bootstrap credentials are documented in the compose .env; log in, then immediately:

    1. Change the admin password.
    2. Enable multi factor authentication for the admin account.
    3. Create your real organization and projects, and stop using the bootstrap admin for day to day work.

    Step 7: Firewall

    shell
    sudo ufw allow OpenSSH
    sudo ufw allow 80/tcp
    sudo ufw allow 443/tcp
    sudo ufw enable

    PostgreSQL stays internal to the Docker network and is never exposed. Port 80 is needed alongside 443 for the ACME HTTP challenge and HTTP to HTTPS redirects.

    Step 8: Register your first application

    With Zitadel running, wire an application to it:

    1. In the console, create a Project.
    2. Inside the project, create an Application and choose the type that matches your client (Web, Native, or API).
    3. Select the OIDC flow appropriate to your app (Authorization Code with PKCE for SPAs and native apps).
    4. Configure the redirect URIs.
    5. Copy the issuer URL (https://auth.example.com) and client ID into your application's OIDC configuration.

    Your application now delegates authentication to Zitadel.

    Email notifications without running a mail server

    Zitadel sends verification and notification emails. RamNode VPS plans are not meant to host a mail server, and self hosted SMTP from a VPS IP rarely reaches inboxes anyway. Configure Zitadel to relay through an external transactional email provider instead. In the console, go to the instance or organization SMTP settings and enter the host, port, and credentials your provider gives you. This keeps Zitadel fully functional without you operating any mail infrastructure.

    Production hardening

    • Externalize PostgreSQL once you scale. For higher throughput or high availability, run PostgreSQL on a dedicated host or managed service rather than the bundled container, and switch to the prodlike overlay so migrations run once in an init container rather than on every replica.
    • Observability. The compose setup includes an optional OpenTelemetry profile. Forward traces to Grafana Tempo, Jaeger, or similar by setting the OTEL backend endpoint, and scrape the Prometheus metrics endpoint.
    • Back up the database and the master key together. A database backup is useless without the master key that decrypts its secrets.

    Backups

    shell
    docker compose exec db pg_dump -U postgres zitadel > zitadel-$(date +%F).sql

    Store the dump and the master key off the VPS. Automate the dump with cron.

    Upgrading

    For an existing database, do not use the combined init command on upgrade. Pull the new image and let the setup and runtime phases run separately, or use the start from setup path, then bring the stack back up:

    shell
    docker compose --env-file .env \
      -f docker-compose.yml \
      -f docker-compose.mode-letsencrypt.yml \
      pull
    docker compose --env-file .env \
      -f docker-compose.yml \
      -f docker-compose.mode-letsencrypt.yml \
      up -d --wait

    Read the release notes between major versions, since identity platforms occasionally ship breaking configuration changes.

    Troubleshooting

    • Login UI loads but fails to authenticate. The login container is not sharing the core service's network namespace or PAT file. Use the unmodified official compose files rather than hand rolling your own, which is the most common cause.
    • Certificate not issued. DNS for your domain is not resolving to the VPS yet, or port 80 is blocked. Confirm both, then recreate the stack.
    • Slow or failing logins under load. You are CPU bound on password hashing. Resize the VPS to more vCPUs.
    • OIDC errors referencing the issuer. You are accessing Zitadel by IP instead of the configured domain. Always use the domain.

    Wrap up

    You now have a TLS protected, self hosted Zitadel instance on a RamNode VPS, with a securely stored master key, an external email relay for notifications, and your first OIDC application connected. From here you can layer in passkeys, enforce MFA policies per organization, and centralize authentication for everything else you host.