Zitadel vs Keycloak: Self-Hosted IAM Compared

Quick Verdict

Zitadel is the better choice for most self-hosters. It ships as a single Go binary, uses a fraction of Keycloak’s memory, and handles OIDC, SAML, MFA, and multi-tenancy out of the box with a modern UI. Keycloak is the right pick only if you need its massive ecosystem of protocol adapters, deep enterprise Java integration, or have a team already fluent in its configuration model. For everyone else, Zitadel gets you to a working SSO setup faster and with far less operational overhead.

What These Projects Do

Both Zitadel and Keycloak are open-source identity and access management (IAM) platforms. They handle user authentication, single sign-on (SSO), multi-factor authentication (MFA), and authorization for your self-hosted applications. Instead of bolting authentication onto every app individually, you run one central identity provider and connect everything to it via OIDC or SAML.

Zitadel is built in Go by a Swiss company (Zitadel AG). It launched in 2020 with a cloud-native, event-sourced architecture. The project ships as a single binary backed by PostgreSQL and targets both small self-hosters and large multi-tenant deployments.

Keycloak is a Red Hat / CNCF project that has been the default open-source IAM solution since 2014. It is built on Quarkus (Java), has a decade-long track record, and supports virtually every enterprise identity protocol and integration pattern in existence.

Feature Comparison

FeatureZitadelKeycloak
OIDC / OAuth 2.0Full support including PKCE, device flow, back-channel logoutFull support with extensive customization
SAML 2.0Supported (IdP and SP)Supported with deep configuration options
MFATOTP, WebAuthn/FIDO2, OTP via email/SMSTOTP, WebAuthn, OTP, conditional policies
RBACBuilt-in roles, project-scoped grantsRealm roles, client roles, composite roles
Multi-tenancyNative — organizations and projects as first-class conceptsVia realms, but 400+ realms degrades performance
Admin UIModern React-based consoleOverhauled admin console (Keycloak 19+)
User self-serviceBuilt-in account portal, profile managementAccount console with theme support
APIgRPC + REST, well-documentedREST admin API, extensive but sprawling
Identity brokeringOIDC, SAML, GitHub, Google, Apple, Azure AD20+ built-in social providers, OIDC, SAML, LDAP
LDAP supportNo native LDAP server; can federate via brokeringFull LDAP/AD federation and user sync
Custom themes / brandingPer-organization login brandingFull theme customization via FreeMarker templates
Event sourcingCore architecture — full audit trailEvent listeners available but not event-sourced
Passkeys / passwordlessWebAuthn/FIDO2 first-classWebAuthn support
LicenseApache 2.0Apache 2.0
LanguageGoJava (Quarkus)
Community size~10k GitHub stars, growing fast~25k GitHub stars, massive ecosystem
Documentation qualityGood, improving rapidlyExtensive but fragmented across versions
Resource usage (idle)~150 MB RAM~750 MB–1.2 GB RAM
Learning curveModerate — clean concepts, fewer knobsSteep — enormous configuration surface

Docker Compose: Zitadel

This configuration runs Zitadel v4.11.0 with the separate login UI and PostgreSQL 17. After startup, access the console at http://localhost:8080/ui/console.

services:
  zitadel:
    image: ghcr.io/zitadel/zitadel:v4.11.0
    command: start-from-init --masterkeyFromEnv
    restart: unless-stopped
    environment:
      ZITADEL_MASTERKEY: "${ZITADEL_MASTERKEY}"
      ZITADEL_EXTERNALDOMAIN: localhost
      ZITADEL_EXTERNALSECURE: "false"
      ZITADEL_TLS_ENABLED: "false"
      ZITADEL_EXTERNALPORT: 8080

      # Database connection
      ZITADEL_DATABASE_POSTGRES_HOST: db
      ZITADEL_DATABASE_POSTGRES_PORT: 5432
      ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
      ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
      ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: "${ZITADEL_DB_PASSWORD}"
      ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
      ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres
      ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: "${POSTGRES_PASSWORD}"
      ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable

      # First instance setup
      ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: "zitadel-admin"
      ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: "${ZITADEL_ADMIN_PASSWORD}"

      # Login v2 configuration
      ZITADEL_FEATUREFLAGS_LOGINV2__0_KEY: login
      ZITADEL_FEATUREFLAGS_LOGINV2__0_VALUE: "true"

      # Login service connection
      ZITADEL_FIRSTINSTANCE_LOGINCLIENT_REDIRECTURIS__0: "http://localhost:3000/loginv2/callback"
      ZITADEL_FIRSTINSTANCE_LOGINCLIENT_POSTLOGOUTREDIRECTURIS__0: "http://localhost:3000/loginv2/logout"
      ZITADEL_FIRSTINSTANCE_LOGINCLIENT_PATPATH: /current-dir/pat
    ports:
      - "8080:8080"
    volumes:
      - .:/current-dir
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "/app/zitadel", "ready"]
      interval: 10s
      timeout: 60s
      retries: 5
      start_period: 10s
    networks:
      - zitadel

  login:
    image: ghcr.io/zitadel/zitadel-login:v4.11.0
    restart: unless-stopped
    environment:
      ZITADEL_API_URL: "http://zitadel:8080"
      ZITADEL_LOGIN_TOKEN_PATH: /current-dir/pat
    ports:
      - "3000:3000"
    volumes:
      - .:/current-dir
    depends_on:
      zitadel:
        condition: service_healthy
    networks:
      - zitadel

  db:
    image: postgres:17
    restart: unless-stopped
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
      POSTGRES_DB: zitadel
    ports:
      - "5432:5432"
    volumes:
      - zitadel-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d zitadel"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s
    networks:
      - zitadel

volumes:
  zitadel-data:

networks:
  zitadel:

Create a .env file alongside the Compose file:

# MUST be exactly 32 characters — used to encrypt secrets at rest
ZITADEL_MASTERKEY=ChangeMe-MustBe32CharsExactly!!

# PostgreSQL root password
POSTGRES_PASSWORD=change-this-postgres-password

# Zitadel database user password
ZITADEL_DB_PASSWORD=change-this-zitadel-db-password

# Initial admin user password (min 8 chars, needs complexity)
ZITADEL_ADMIN_PASSWORD=Admin1234!

Start it:

docker compose up -d

Log in at http://localhost:8080/ui/console with user [email protected] and the password from your .env file.

Docker Compose: Keycloak

This configuration runs Keycloak 26.5.4 in production mode with PostgreSQL 17. After startup, access the admin console at http://localhost:8080.

services:
  keycloak:
    image: quay.io/keycloak/keycloak:26.5.4
    command: start
    restart: unless-stopped
    environment:
      # Admin credentials
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: "${KC_ADMIN_PASSWORD}"

      # Database configuration
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: "${KC_DB_PASSWORD}"

      # Hostname and proxy settings
      # Set KC_HOSTNAME to your actual domain in production
      KC_HOSTNAME: localhost
      KC_HOSTNAME_STRICT: "false"
      KC_HTTP_ENABLED: "true"
      KC_PROXY_HEADERS: xforwarded

      # Health and metrics
      KC_HEALTH_ENABLED: "true"
      KC_METRICS_ENABLED: "true"
    ports:
      - "8080:8080"
      - "9000:9000"
    depends_on:
      keycloak-db:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3; timeout 1 cat <&3 | grep -q '200'"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    networks:
      - keycloak

  keycloak-db:
    image: postgres:17
    restart: unless-stopped
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: "${KC_DB_PASSWORD}"
    volumes:
      - keycloak-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s
    networks:
      - keycloak

volumes:
  keycloak-data:

networks:
  keycloak:

Create a .env file:

# Keycloak admin password
KC_ADMIN_PASSWORD=change-this-admin-password

# PostgreSQL password for Keycloak
KC_DB_PASSWORD=change-this-keycloak-db-password

Start it:

docker compose up -d

Log in at http://localhost:8080 with admin and the password from your .env file. Keycloak’s first startup takes 30-60 seconds while it initializes the database schema.

Setup Complexity

This is where the two projects diverge sharply.

Zitadel gets you to a working SSO provider in under five minutes. The start-from-init command handles database migrations, initial setup, and first-user creation in a single step. The concept model is straightforward: you have organizations, projects within organizations, and applications within projects. Creating an OIDC client for a new app takes about 30 seconds through the console UI.

Keycloak demands more upfront investment. The concept model alone requires understanding realms, clients, client scopes, protocol mappers, authentication flows, identity providers, and user federation — before you configure your first app. The admin console is powerful but dense. Expect to spend 30-60 minutes reading documentation before your first OIDC client is correctly configured, especially if you need to customize token claims or authentication flows.

Where this gap widens further:

  • Theme customization. Zitadel lets you upload branding assets per organization through the UI. Keycloak requires building FreeMarker templates, packaging them into a JAR, and mounting them into the container.
  • Multi-tenancy. Zitadel’s organization model is built for it. Keycloak uses realms, which work but carry per-realm memory overhead that becomes problematic past ~400 realms.
  • Upgrades. Zitadel’s event-sourced architecture means database migrations are typically non-destructive. Keycloak upgrades between major versions (especially the Wildfly-to-Quarkus migration) have historically been painful.

Keycloak earns its complexity back in one area: protocol depth. If you need LDAP federation, Kerberos, custom authentication SPI plugins, or fine-grained authorization policies, Keycloak’s configuration surface exists because it covers more edge cases than any other open-source IAM.

Performance and Resource Usage

The difference in resource consumption is dramatic and stems directly from the technology stack.

Zitadel (Go binary):

  • Idle memory: ~100-150 MB
  • Under moderate load (100 auth requests/sec): ~300-500 MB
  • CPU: minimal at idle, spikes during password hashing
  • Startup time: 5-10 seconds
  • Production recommendation: 512 MB RAM, <1 CPU core for the binary; 4 CPU cores recommended to handle password hashing spikes

Keycloak (Java/Quarkus):

  • Idle memory: ~750 MB-1.2 GB (includes realm caches, session caches)
  • Under moderate load: 1.5-2.5 GB depending on active sessions and cached realms
  • CPU: moderate at idle due to JVM overhead
  • Startup time: 30-60 seconds (database initialization on first run can take longer)
  • Production recommendation: 2 GB RAM minimum, 4 GB recommended; 2+ CPU cores

For a self-hoster running on a Raspberry Pi 4 or a low-end VPS, Zitadel’s footprint is a significant advantage. You can comfortably run Zitadel alongside a dozen other services on a 4 GB machine. Keycloak on the same hardware means tight memory budgets and potential OOM kills under load.

Keycloak’s Infinispan-based caching does pay off at scale — if you have thousands of concurrent sessions across multiple realms, the in-memory session cache delivers lower latency than hitting the database. But most self-hosters are nowhere near that scale.

Use Cases

Choose Zitadel If…

  • You want SSO for your homelab apps and do not want to spend a weekend configuring it
  • Resource efficiency matters — you are running on a small VPS or shared hardware
  • You need multi-tenancy (multiple organizations, each with their own users and branding)
  • You prefer a modern admin UI over XML-like configuration
  • You want an event-sourced audit trail of every identity change
  • You value fast startup and low operational overhead
  • Your apps primarily use OIDC/OAuth 2.0 (most modern self-hosted apps do)

Choose Keycloak If…

  • You need LDAP or Active Directory federation with user sync
  • Your organization already has Keycloak expertise on the team
  • You need protocol adapters beyond OIDC and SAML (Kerberos, custom SPIs)
  • You require fine-grained authorization policies (UMA, role-based policy evaluation)
  • You need the massive library of community-built extensions and themes
  • You are integrating with enterprise Java applications that expect Keycloak-specific adapters
  • You want the backing of a CNCF project with a 10+ year track record

Final Verdict

For the typical self-hoster connecting Nextcloud, Gitea, Grafana, and a handful of other apps to a central SSO provider, Zitadel is the better choice. It uses less memory, starts faster, has cleaner concepts, and gets you to a working configuration with less friction. The Go binary architecture means fewer moving parts and more predictable resource usage.

Keycloak remains the right answer for enterprise environments with LDAP dependencies, complex authorization requirements, or teams that already know it. Its protocol coverage is unmatched. But that coverage comes with operational weight that most self-hosters do not need.

If you are starting fresh and your apps speak OIDC (most do), start with Zitadel. You can always migrate later if you hit a wall — though with Zitadel’s pace of development, that wall keeps moving further out.

Comments