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
| Feature | Zitadel | Keycloak |
|---|---|---|
| OIDC / OAuth 2.0 | Full support including PKCE, device flow, back-channel logout | Full support with extensive customization |
| SAML 2.0 | Supported (IdP and SP) | Supported with deep configuration options |
| MFA | TOTP, WebAuthn/FIDO2, OTP via email/SMS | TOTP, WebAuthn, OTP, conditional policies |
| RBAC | Built-in roles, project-scoped grants | Realm roles, client roles, composite roles |
| Multi-tenancy | Native — organizations and projects as first-class concepts | Via realms, but 400+ realms degrades performance |
| Admin UI | Modern React-based console | Overhauled admin console (Keycloak 19+) |
| User self-service | Built-in account portal, profile management | Account console with theme support |
| API | gRPC + REST, well-documented | REST admin API, extensive but sprawling |
| Identity brokering | OIDC, SAML, GitHub, Google, Apple, Azure AD | 20+ built-in social providers, OIDC, SAML, LDAP |
| LDAP support | No native LDAP server; can federate via brokering | Full LDAP/AD federation and user sync |
| Custom themes / branding | Per-organization login branding | Full theme customization via FreeMarker templates |
| Event sourcing | Core architecture — full audit trail | Event listeners available but not event-sourced |
| Passkeys / passwordless | WebAuthn/FIDO2 first-class | WebAuthn support |
| License | Apache 2.0 | Apache 2.0 |
| Language | Go | Java (Quarkus) |
| Community size | ~10k GitHub stars, growing fast | ~25k GitHub stars, massive ecosystem |
| Documentation quality | Good, improving rapidly | Extensive but fragmented across versions |
| Resource usage (idle) | ~150 MB RAM | ~750 MB–1.2 GB RAM |
| Learning curve | Moderate — clean concepts, fewer knobs | Steep — 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.
Related
Get self-hosting tips in your inbox
New guides, comparisons, and setup tutorials — delivered weekly. No spam.
Comments