How to Deploy Zitadel on a VPS: Modern Identity Platform for Auth and User Management

How to Deploy Zitadel on a VPS: Modern Identity Platform for Auth and User Management

Zitadel is a cloud-native identity platform built for modern infrastructure — it provides OIDC/OAuth2, SAML, machine-to-machine authentication (service accounts), passkeys/WebAuthn, multi-tenancy, and a comprehensive admin console. Compared to Keycloak (heavy Java application, XML configuration, steep learning curve), Zitadel is a single Go binary, configured entirely through its UI and APIs, and significantly easier to deploy and maintain on a VPS.

Zitadel vs Keycloak: Key Differences

Factor Zitadel Keycloak
Language Go (fast, low memory) Java/Quarkus (JVM overhead)
Min RAM ~256 MB ~512 MB–1 GB
Configuration UI + API-first XML + UI (complex)
Passkeys/WebAuthn Built-in, first-class Available but less polished
M2M auth Excellent (service accounts) Client credentials flow
Multi-tenancy Built-in (Organizations) Realms (more complex)
Database PostgreSQL or CockroachDB H2/PostgreSQL/MySQL

Server Requirements

  • 2 GB RAM (Zitadel itself uses 256 MB; PostgreSQL adds 200–500 MB)
  • Docker and Docker Compose
  • A domain name (Zitadel requires HTTPS)

Step 1: Docker Compose Setup

<code">mkdir -p /opt/zitadel && cd /opt/zitadel
nano docker-compose.yml
<code">version: '3.8'

services:
  zitadel:
    image: 'ghcr.io/zitadel/zitadel:stable'
    container_name: zitadel
    restart: always
    command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled'
    environment:
      ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db
      ZITADEL_DATABASE_POSTGRES_PORT: 5432
      ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
      ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
      ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${POSTGRES_PASSWORD}
      ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
      ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres
      ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${POSTGRES_ADMIN_PASSWORD}
      ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
      ZITADEL_EXTERNALDOMAIN: auth.yourdomain.com
      ZITADEL_EXTERNALPORT: 443
      ZITADEL_EXTERNALSECURE: true
      ZITADEL_TLS_ENABLED: false   # Nginx handles TLS
    ports:
      - "127.0.0.1:8080:8080"
    depends_on:
      zitadel-db:
        condition: service_healthy

  zitadel-db:
    image: postgres:16-alpine
    container_name: zitadel-db
    restart: always
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD}
    volumes:
      - zitadel_db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  zitadel_db:
<code">nano .env
<code">POSTGRES_PASSWORD=StrongZitadelDbPassword!
POSTGRES_ADMIN_PASSWORD=StrongAdminDbPassword!
<code">chmod 600 .env
docker compose up -d
docker compose logs -f zitadel   # Wait for "server is listening on [::]:8080"

Step 2: Nginx Reverse Proxy

<code">sudo nano /etc/nginx/sites-available/zitadel
<code">server {
    listen 80;
    server_name auth.yourdomain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name auth.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/auth.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/auth.yourdomain.com/privkey.pem;

    # Required for Zitadel gRPC (used internally)
    http2_push_preload on;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 300s;
        grpc_set_header Host $host;
    }
}
<code">sudo ln -s /etc/nginx/sites-available/zitadel /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d auth.yourdomain.com

Step 3: First Login and Setup

  1. Visit https://auth.yourdomain.com
  2. Default admin credentials are shown in the first-run output: docker compose logs zitadel | grep "initial admin"
  3. Log in → Change password immediately
  4. Navigate to Instance Settings → Custom Domain → add auth.yourdomain.com

Step 4: Create an OIDC Application

  1. Projects → Create New Project (e.g., “My App”)
  2. Applications → New → Web Application → OIDC
  3. Authentication method: PKCE (for SPAs) or JWT Profile (for server-to-server)
  4. Redirect URIs: https://yourdomain.com/auth/callback
  5. Post Logout URIs: https://yourdomain.com
  6. Save and copy the Client ID

Step 5: Integrate with Next.js (NextAuth)

<code"># pages/api/auth/[...nextauth].js
import NextAuth from 'next-auth';

export default NextAuth({
  providers: [
    {
      id: 'zitadel',
      name: 'Zitadel',
      type: 'oauth',
      wellKnown: 'https://auth.yourdomain.com/.well-known/openid-configuration',
      authorization: { params: { scope: 'openid email profile' } },
      clientId: process.env.ZITADEL_CLIENT_ID,
      idToken: true,
      checks: ['pkce', 'state'],
      profile(profile) {
        return {
          id: profile.sub,
          name: profile.name,
          email: profile.email,
          image: profile.picture,
        };
      },
    },
  ],
  callbacks: {
    async jwt({ token, account }) {
      if (account) {
        token.accessToken = account.access_token;
        token.idToken = account.id_token;
      }
      return token;
    },
    async session({ session, token }) {
      session.accessToken = token.accessToken;
      return session;
    },
  },
});

Machine-to-Machine Authentication

Zitadel excels at service-to-service auth via JWT Bearer tokens:

<code"># Create a Service User in Zitadel console:
# Users → Service Users → New → add name and description
# Security → Keys → Add → JSON key type → download key file

import jwt
import time
import json
import requests

# Load key file
with open('service_account_key.json') as f:
    key = json.load(f)

# Create JWT for token exchange
now = int(time.time())
token = jwt.encode({
    'iss': key['userId'],
    'sub': key['userId'],
    'aud': 'https://auth.yourdomain.com',
    'iat': now,
    'exp': now + 3600,
}, key['key'], algorithm='RS256', headers={'kid': key['keyId']})

# Exchange for access token
response = requests.post('https://auth.yourdomain.com/oauth/v2/token', data={
    'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
    'scope': 'openid urn:zitadel:iam:org:project:id:YOUR_PROJECT_ID:aud',
    'assertion': token,
})
access_token = response.json()['access_token']

Getting Started

Zitadel on a 2 GB Ubuntu VPS at VPS.DO handles authentication for hundreds of applications with hundreds of thousands of users. The Go binary is memory-efficient (256 MB idle), and PostgreSQL on the same VPS is sufficient for most organizational deployments. For teams building multi-tenant SaaS applications, Zitadel’s built-in Organizations feature manages tenant isolation natively.

Conclusion

Zitadel is the modern, developer-friendly alternative to Keycloak for self-hosted identity infrastructure. A single Go binary (256 MB RAM), UI-first configuration, first-class passkeys/WebAuthn support, and excellent machine-to-machine authentication make it significantly easier to operate than Keycloak while covering the same OIDC/OAuth2/SAML use cases. Self-hosting replaces Auth0’s $23+/month or Okta’s premium pricing with VPS infrastructure cost.

Fast • Reliable • Affordable VPS - DO It Now!

Get top VPS hosting with VPS.DO’s fast, low-cost plans. Try risk-free with our 7-day no-questions-asked refund and start today!