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
- Visit
https://auth.yourdomain.com - Default admin credentials are shown in the first-run output:
docker compose logs zitadel | grep "initial admin" - Log in → Change password immediately
- Navigate to Instance Settings → Custom Domain → add
auth.yourdomain.com
Step 4: Create an OIDC Application
- Projects → Create New Project (e.g., “My App”)
- Applications → New → Web Application → OIDC
- Authentication method: PKCE (for SPAs) or JWT Profile (for server-to-server)
- Redirect URIs:
https://yourdomain.com/auth/callback - Post Logout URIs:
https://yourdomain.com - 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.