Deployment guide

Self-Hosting ZITADEL on a VPS: Multi-Tenant SSO Without Keycloak

Deploy on a VPS Cloud →

Tutorial

Self-Hosting ZITADEL on a VPS: Multi-Tenant SSO Without Keycloak

Security & Monitoring14 min read10 steps

ZITADEL is a cloud-native identity provider written in Go, built from the ground up for B2B teams managing multiple clients or distinct projects. Where Keycloak requires configuring a realm per tenant — with all the configuration drift risk that entails — and where Authentik treats multi-tenancy as an additional feature, ZITADEL places organizations and projects at the core of its data model. Each tenant has its own users, its own authentication rules, and its own applications, without ever interfering with neighbors. Its memory footprint is that of a Go binary — 150 to 300 MB for the full stack with PostgreSQL, versus 512 MB to 1 GB of JVM for Keycloak at rest. It supports OIDC, OAuth2, SAML 2.0, LDAP and WebAuthn Passkeys since version 2.x, with a gRPC/REST API that allows full automation of tenant management without ever touching a graphical interface.

Contents· ZITADEL vs Keycloak vs Authentik: which to choose for a B2B SaaS1/10
  1. 01ZITADEL vs Keycloak vs Authentik: which to choose for a B2B SaaS
  2. 02Why ZITADEL stands out for agency or B2B SaaS use
  3. 03Prerequisites: server, Docker and DNS
  4. 04Deploy ZITADEL with Docker Compose and PostgreSQL
  5. 05Configuring OIDC authentication for your applications
  6. 06Multi-tenant model: organizations, projects and applications
  7. 07ZITADEL vs Authentik vs Keycloak — technical comparison
  8. 08Enabling Passkeys and MFA in ZITADEL v2
  9. 09Troubleshooting: common ZITADEL Docker errors
  10. 10Next step: deepen your self-hosted SSO stack

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

  1. Create the working directory tree

    Connect to your VPS via SSH and create the necessary directories:

    mkdir -p /opt/zitadel/{config,certs}
    cd /opt/zitadel

    All configuration files and secrets will live here.

  2. 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
    EOF

    The MASTERKEY must be exactly 32 characters — ZITADEL refuses to start without this.

  3. Write the docker-compose.yml

    Create /opt/zitadel/docker-compose.yml with the PostgreSQL and ZITADEL services, configuring the database connection via environment variables and exposing port 8080 for nginx to proxy.

  4. Configure nginx as TLS reverse proxy

    Create an nginx server block for sso.your-domain.com that redirects HTTP to HTTPS and proxies requests to http://127.0.0.1:8080. Set the X-Forwarded-Proto: https header. Obtain the Let's Encrypt certificate with Certbot and reload nginx.

  5. Start the ZITADEL stack

    cd /opt/zitadel
    docker compose up -d
    docker compose logs -f zitadel

    The first start (start-from-init) initializes the database, applies migrations and creates the default organization. Wait for server is listening on [::]:8080 in the logs — this typically takes 30 to 60 seconds.

  6. 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-init after clearing the database.

  7. Log in to the administration console

    Open https://sso.your-domain.com/ui/console in 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.

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

  9. 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 ID and Client Secret. Note the Discovery URL: https://sso.your-domain.com/.well-known/openid-configuration — all OIDC-configured applications point to this endpoint.

  10. 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 the stable tag with the exact version number in docker-compose.yml and pin it — stable tracks 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 email

The 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

CriterionZITADEL v2.xAuthentik 2026.8Keycloak 26.7
Language / runtimeGo — single binaryPython + Rust (frontend)Java (JVM)
Full stack RAM (idle)150 – 300 MB~1 GB (worker + PostgreSQL)512 MB – 1 GB (JVM alone)
Native multi-tenancyYes — Organizations primitivePartial — separate tenantsPartial — separate realms
OIDC / OAuth2 / SAMLYes — all three nativeYes — all three nativeYes — all three native
Passkeys / WebAuthnYes, native v2Yes (2026.2+)Yes (v21+)
API-firstgRPC + REST, 100% coveragePartial RESTPartial REST
Admin interfaceWeb console + APIFull UI with visual flowsFull UI, XML configuration
LicenceApache 2.0 (core)MIT (Community) / EnterpriseApache 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.

Deploy your infrastructure today

VPS Cloud. Preconfigured environments available.

Need help?

Browse our help center and FAQ, or reach our team — callback, WhatsApp or email. Support in French, English and Arabic.

Message us on WhatsAppopens in a new tab