ZITADEL vs Keycloak vs Authentik: which to choose for a B2B SaaS
The question comes up systematically whenever an agency or SaaS developer wants to centralize authentication for multiple clients: stick with Keycloak, switch to Authentik, or look at ZITADEL?
Keycloak is the historical standard, feature-rich and backed by Red Hat. It covers OIDC, SAML, LDAP, Kerberos, WebAuthn and has exhaustive documentation. Its problem for self-hosted VPS: the JVM. At rest, Keycloak consumes between 512 MB and 1 GB of RAM, and each realm is a separate configuration tree to maintain. Week 38 of 2026 illustrated the risk: six CVEs fixed in two consecutive releases, including two unauthenticated DoS attacks on SAML and login endpoints.
Authentik (2026.8.2) is the natural choice for teams wanting a full IdP with a graphical interface, visual flows and SCIM provisioning. It is written in Python/Django with a TypeScript frontend, and its full stack requires at least 1 GB of usable RAM. Multi-tenancy exists, but is implemented via separate tenants or groups — not via a dedicated data model.
ZITADEL (v2.x, Go) is the only one of the three to have designed multi-tenancy as a first-class primitive. A single installation exposes independent organizations — each with its own users, external IdPs, password policies and applications. For an agency hosting ten clients on a single VPS, that is the difference between ten Keycloak configuration files and an API that orchestrates everything.
Why ZITADEL stands out for agency or B2B SaaS use
- Native multi-tenancy: organizations and projects are data model primitives, not workarounds — each client gets an isolated space without configuration duplication
- Written in Go: single binary, seconds to start, memory footprint of 150 to 300 MB full stack versus 512 MB to 1 GB of JVM for Keycloak
- OIDC, OAuth2, SAML 2.0 and LDAP: all enterprise protocols in a single installation — no need to add extensions as with Keycloak
- Native Passkeys and WebAuthn from v2: each organization can enforce Passkeys as the sole authentication factor, without plugins or advanced configuration
- API-first gRPC/REST: every operation available in the console is also available via the API — provision a tenant, create an OIDC application, or reset an MFA in a few lines of script
- Actions (webhooks/code): custom logic triggered on authentication events (pre/post-auth, post-register) without modifying the core
- Stateless application deployment: ZITADEL stores everything in PostgreSQL — no distributed cache, no additional volume, horizontal scaling is as simple as starting a second instance
- Apache 2.0 licence: the core is fully open source, with no critical features hidden behind an Enterprise licence
Prerequisites: server, Docker and DNS
ZITADEL is reasonable on resources for an identity provider of this scope. The team-recommended minimum configuration for a production deployment is 2 vCPU and 4 GB of RAM — of which about 300 MB will be used by ZITADEL itself, the rest going to PostgreSQL and other services in your stack.
In practice, on a dedicated VPS with 4 GB of RAM, ZITADEL + PostgreSQL leave plenty of room for an nginx reverse proxy, a notification service and a few applications. For testing or a development deployment, 2 GB is sufficient.
Required software: Docker Engine 24+ and Docker Compose v2. Ports 80 and 443 must be open (nginx will terminate TLS). A domain name pointed at your VPS IP — ZITADEL requires a FQDN even in development, as OIDC cookies are bound to the domain. If you do not yet have a TLS certificate, Let's Encrypt with Certbot or the nginx plugin is sufficient.
Database: PostgreSQL 15 or 16. ZITADEL also supports CockroachDB for highly available deployments, but on a single-node VPS, PostgreSQL is the natural choice. The database must be reachable from the ZITADEL container — the default Docker bridge network is fine.
Storage: ZITADEL stores no files on disk (beyond logs) — no dedicated data volume beyond the PostgreSQL volume. Plan for 10 GB for the database under normal usage conditions.
Deploy ZITADEL with Docker Compose and PostgreSQL
Create the working directory tree
Connect to your VPS via SSH and create the necessary directories:
mkdir -p /opt/zitadel/{config,certs} cd /opt/zitadelAll configuration files and secrets will live here.
Create the .env file with secrets
Generate strong secrets before writing them to
.env:cat > /opt/zitadel/.env <<EOF POSTGRES_PASSWORD=$(openssl rand -base64 32 | tr -d '\n') ZITADEL_MASTERKEY=$(openssl rand -base64 32 | tr -d '\n' | head -c 32) ZITADEL_DOMAIN=sso.your-domain.com EOFThe
MASTERKEYmust be exactly 32 characters — ZITADEL refuses to start without this.Write the docker-compose.yml
Create
/opt/zitadel/docker-compose.ymlwith the PostgreSQL and ZITADEL services, configuring the database connection via environment variables and exposing port 8080 for nginx to proxy.Configure nginx as TLS reverse proxy
Create an nginx server block for
sso.your-domain.comthat redirects HTTP to HTTPS and proxies requests tohttp://127.0.0.1:8080. Set theX-Forwarded-Proto: httpsheader. Obtain the Let's Encrypt certificate with Certbot and reload nginx.Start the ZITADEL stack
cd /opt/zitadel docker compose up -d docker compose logs -f zitadelThe first start (
start-from-init) initializes the database, applies migrations and creates the default organization. Wait forserver is listening on [::]:8080in the logs — this typically takes 30 to 60 seconds.Retrieve initial admin credentials
During the first start with
start-from-init, ZITADEL generates an admin account and a service key file. Retrieve them with:docker compose logs zitadel 2>&1 | grep -A 5 'initial'Credentials are displayed only once in the logs. Note the login and password — they cannot be recovered after. If lost, restart with
start-from-initafter clearing the database.Log in to the administration console
Open
https://sso.your-domain.com/ui/consolein a browser. Sign in with the credentials retrieved in the previous step. You arrive at the default organization dashboard — this is your main organization (your own account or agency). From here, you can create client organizations, projects and OIDC applications.Create your first client organization
In the console, go to Organization → New organization. Give it your client's name (e.g.
client-acme). ZITADEL automatically creates an isolated space: users in this organization cannot see those in others, and password policies can differ. You can then invite an organization admin for the client — they will have access only to their space.Create a project and an OIDC application
In the organization, go to Projects → New project. A project groups one or more applications. Then create a Web application of type OIDC. ZITADEL asks for the client type (Web, SPA, Native) and redirect URLs. It generates a
Client IDandClient Secret. Note the Discovery URL:https://sso.your-domain.com/.well-known/openid-configuration— all OIDC-configured applications point to this endpoint.Verify health and pin the image version
curl -s https://sso.your-domain.com/healthz | jq .A
{"status": "pass"}response confirms ZITADEL is responding correctly. For updates, replace thestabletag with the exact version number indocker-compose.ymland pin it —stabletracks the latest release, which can introduce unplanned changes in production.
Configuring OIDC authentication for your applications
Once ZITADEL is running and an OIDC application is created, the client-side configuration is standard — any OIDC library (passport.js, next-auth, python-oidc, etc.) works without ZITADEL-specific adaptation.
The essential OIDC parameters to provide to your application:
ISSUER=https://sso.your-domain.com
CLIENT_ID=<generated_client_id>
CLIENT_SECRET=<generated_client_secret> # only for Web apps (not SPAs)
REDIRECT_URI=https://your-app.com/callback
SCOPES=openid profile emailThe Discovery Document is available at https://sso.your-domain.com/.well-known/openid-configuration. It automatically exposes all endpoints: authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri. Most modern OIDC libraries can configure themselves from this single discovery URL.
For SPAs (React, Vue), use the PKCE flow without a client-side secret. ZITADEL handles PKCE code rotation natively — no additional configuration required.
Multi-tenant model: organizations, projects and applications
The ZITADEL data model is structured in three levels that precisely match the needs of an agency or B2B SaaS.
The organization is a tenant container. Each organization has its own users, its own external IdPs (Google, GitHub, enterprise SAML) and its own password complexity policies. It can be administered by an organization delegate — your client — without giving them access to other organizations.
The project groups the applications of a single product. A project can contain a web application, a mobile application and an API — all sharing the same roles and permissions, defined once at the project level. JWT tokens issued by ZITADEL carry project roles in the urn:zitadel:iam:org:project:roles claim.
The application represents a specific OIDC, OAuth2 or SAML client. It defines redirect URLs, client type and allowed scopes.
This model allows an agency to manage ten clients on a single ZITADEL installation with zero risk of cross-tenant contamination — compare with Keycloak where each realm is a complete configuration to maintain separately.
ZITADEL vs Authentik vs Keycloak — technical comparison
Scroll the table
| Criterion | ZITADEL v2.x | Authentik 2026.8 | Keycloak 26.7 |
|---|---|---|---|
| Language / runtime | Go — single binary | Python + Rust (frontend) | Java (JVM) |
| Full stack RAM (idle) | 150 – 300 MB | ~1 GB (worker + PostgreSQL) | 512 MB – 1 GB (JVM alone) |
| Native multi-tenancy | Yes — Organizations primitive | Partial — separate tenants | Partial — separate realms |
| OIDC / OAuth2 / SAML | Yes — all three native | Yes — all three native | Yes — all three native |
| Passkeys / WebAuthn | Yes, native v2 | Yes (2026.2+) | Yes (v21+) |
| API-first | gRPC + REST, 100% coverage | Partial REST | Partial REST |
| Admin interface | Web console + API | Full UI with visual flows | Full UI, XML configuration |
| Licence | Apache 2.0 (core) | MIT (Community) / Enterprise | Apache 2.0 |
Enabling Passkeys and MFA in ZITADEL v2
ZITADEL v2 offers WebAuthn Passkeys as a primary authentication factor — not just as a second factor. To enable them in an organization:
1. In the console, open Organization → Security Policy → MFA Policy.
2. Enable Passkeys (passwordless WebAuthn) and select Force if you want all users in this organization to use only Passkeys.
3. For a more flexible policy: allow both Passkeys and TOTP — the user chooses during enrollment.
To enforce MFA by default for all new users of a project:
- Open the Project → Settings → Force MFA — this option requires every user of that project to register a second factor before accessing the application.
Passkeys are stored client-side (in the browser or on a FIDO2 security key) and never transmitted to ZITADEL — only the cryptographic proof is exchanged. ZITADEL also supports standard TOTP (Google Authenticator, Bitwarden Authenticator, any RFC 6238-compatible app) and physical U2F/FIDO2 security keys (YubiKey, etc.) for enterprise deployments.
Troubleshooting: common ZITADEL Docker errors
masterkey must be 32 characters: the ZITADEL_MASTERKEY generated by openssl rand -base64 32 produces a base64 string of variable length due to padding. Use head -c 32 to truncate to exactly 32 characters, or use openssl rand -hex 16 (32 hex characters = 16 bytes).
connection refused on port 8080 while the container is running: ZITADEL only responds on :8080 once migrations are complete — start-from-init on an empty database can take 30 to 90 seconds. Wait for the log server is listening on [::]:8080 before testing.
HTTP/HTTPS redirect loop: ZITADEL generates its OAuth2 redirects in HTTPS when ZITADEL_EXTERNALSECURE=true. If nginx does not correctly send the X-Forwarded-Proto: https header, ZITADEL thinks it is behind HTTP and generates broken redirects. Verify that proxy_set_header X-Forwarded-Proto https is in nginx's location / block.
invalid_client during OIDC flow: verify that the redirect URL declared in the ZITADEL application matches exactly what your application sends — ZITADEL is strict about matching (no trailing slash, no extra parameters).
PostgreSQL connection refused: the ZITADEL container starts before PostgreSQL is ready to accept connections, even with depends_on: condition: service_healthy. If this occurs, simply restart ZITADEL: docker compose restart zitadel.
Lost initial admin credentials: credentials are displayed only once at first startup. If lost, remove the PostgreSQL database (docker compose down -v) and restart start-from-init. In production, immediately create a second admin account and store credentials in your secrets manager.
Next step: deepen your self-hosted SSO stack
ZITADEL in production on a VPS is an excellent first step. To go further:
- [Deploy ZITADEL on VPS — advanced guide](/deployer-zitadel-vps): automatic TLS certificates with Let's Encrypt, PostgreSQL backup with pg_dump to S3, and zero-downtime updates.
- [Authentik vs Authelia: which self-hosted SSO for your VPS?](/authentik-vs-authelia-sso-self-hosted): if your use case is less multi-tenant-oriented and more authentication proxy-oriented, the comparison of the two lightest alternatives.
- [Migrating from Keycloak to Authelia or ZITADEL](/keycloak-cves-semaine-38-migrer-authelia-zitadel-vps): the six week-38 CVEs analyzed, and the concrete migration path from an existing Keycloak realm.
- [Self-hosting Authelia on VPS](/self-host-authelia-vps): if you are looking for an lightweightweight authentication gateway (less than 30 MB) rather than a full IdP, Authelia remains the reference.
A VPS with 4 GB of RAM, root access and a dedicated IPv4 comfortably covers ZITADEL in production with room to spare for the applications it protects.