Skip to content

Zitadel Production Deployment

John R. D'Orazio edited this page Apr 2, 2026 · 4 revisions

Zitadel Production Deployment

This page covers the steps to deploy Zitadel and the RBAC system to a production server.

Hosting Options

Option A: Co-located (recommended to start)

Run Zitadel, Login V2, and PostgreSQL on the same server as the API using Docker Compose. Simpler to manage, single server.

Option B: Separate identity server

Run Zitadel on a dedicated host/VPS. Better isolation and scalability, but more infrastructure to manage.

Either way, the steps are similar.

Step 1: Generate Secrets

Generate all required secrets before configuring anything:

# Zitadel master key (must be exactly 32 characters)
openssl rand -hex 16

# PostgreSQL passwords (one for each user)
openssl rand -base64 24  # for postgres superuser
openssl rand -base64 24  # for zitadel user
openssl rand -base64 24  # for litcal user

# JWT secret (minimum 32 characters)
php -r "echo bin2hex(random_bytes(32));"

# Admin password hash (for env-based fallback admin)
php -r "echo password_hash('your-secure-password', PASSWORD_ARGON2ID);"

Step 2: DNS Configuration

Create DNS records for subdomains. For example:

Subdomain Points To Purpose
auth.liturgicalcalendar.com Server IP Zitadel
login.liturgicalcalendar.com Server IP Login V2
api.liturgicalcalendar.com Server IP API

Step 3: TLS Certificates

Obtain TLS certificates for each subdomain. Using Let's Encrypt with certbot:

certbot certonly --standalone -d auth.liturgicalcalendar.com
certbot certonly --standalone -d login.liturgicalcalendar.com
certbot certonly --standalone -d api.liturgicalcalendar.com

Or use a wildcard certificate:

certbot certonly --manual --preferred-challenges dns -d "*.liturgicalcalendar.com"

Step 4: Update docker-compose.yml for Production

Key changes from the development configuration:

Zitadel Service

zitadel:
  image: ghcr.io/zitadel/zitadel:latest
  command: 'start-from-init --masterkey "<generated-32-char-key>"'
  environment:
    # Domain configuration
    ZITADEL_EXTERNALDOMAIN: auth.liturgicalcalendar.com
    ZITADEL_EXTERNALSECURE: true
    ZITADEL_EXTERNALPORT: 443

    # TLS - handled by reverse proxy, not Zitadel itself
    ZITADEL_TLS_ENABLED: false

    # Database - use strong passwords
    ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: <generated-password>
    ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: <generated-password>

    # Login V2 URLs - update to production domain
    ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: https://login.liturgicalcalendar.com/ui/v2/login
    ZITADEL_OIDC_DEFAULTLOGINURLV2: https://login.liturgicalcalendar.com/ui/v2/login/login?authRequest=
    ZITADEL_OIDC_DEFAULTLOGOUTURLV2: https://login.liturgicalcalendar.com/ui/v2/login/logout?post_logout_redirect=
    ZITADEL_SAML_DEFAULTLOGINURLV2: https://login.liturgicalcalendar.com/ui/v2/login/login?samlRequest=

    # Reduce log verbosity
    ZITADEL_LOG_LEVEL: warn

    # Organization
    ZITADEL_FIRSTINSTANCE_ORG_NAME: "LiturgicalCalendar"
    ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: root
    ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: <strong-password>
    ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: true

Login V2 Service

login:
  image: ghcr.io/zitadel/zitadel-login:latest
  environment:
    ZITADEL_API_URL: https://auth.liturgicalcalendar.com
    NEXT_PUBLIC_BASE_PATH: /ui/v2/login
    ZITADEL_SERVICE_USER_TOKEN_FILE: /current-dir/login-client.pat
    EMAIL_VERIFICATION: true

PostgreSQL Service

db:
  image: postgres:17
  environment:
    POSTGRES_PASSWORD: <generated-password>

Remove Adminer

Do not expose Adminer in production. Remove the adminer service entirely from the compose file, or restrict it to internal networks only.

Step 5: Configure Reverse Proxy

Nginx

# Zitadel
server {
    listen 443 ssl http2;
    server_name auth.liturgicalcalendar.com;

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

    location / {
        proxy_pass http://localhost:8080;
        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;

        # Required for gRPC-web (Zitadel Console)
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

# Login V2
server {
    listen 443 ssl http2;
    server_name login.liturgicalcalendar.com;

    ssl_certificate /etc/letsencrypt/live/login.liturgicalcalendar.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/login.liturgicalcalendar.com/privkey.pem;

    location / {
        proxy_pass http://localhost:8081;
        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;
    }
}

# API
server {
    listen 443 ssl http2;
    server_name api.liturgicalcalendar.com;

    ssl_certificate /etc/letsencrypt/live/api.liturgicalcalendar.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.liturgicalcalendar.com/privkey.pem;

    location / {
        proxy_pass http://localhost:8000;
        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;
    }
}

# HTTP to HTTPS redirects
server {
    listen 80;
    server_name auth.liturgicalcalendar.com login.liturgicalcalendar.com api.liturgicalcalendar.com;
    return 301 https://$host$request_uri;
}

Apache

<VirtualHost *:443>
    ServerName auth.liturgicalcalendar.com
    SSLEngine on
    SSLCertificateFile /etc/letsencrypt/live/auth.liturgicalcalendar.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/auth.liturgicalcalendar.com/privkey.pem

    ProxyPreserveHost On
    ProxyPass / http://localhost:8080/
    ProxyPassReverse / http://localhost:8080/
    RequestHeader set X-Forwarded-Proto "https"
</VirtualHost>

<VirtualHost *:443>
    ServerName login.liturgicalcalendar.com
    SSLEngine on
    SSLCertificateFile /etc/letsencrypt/live/login.liturgicalcalendar.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/login.liturgicalcalendar.com/privkey.pem

    ProxyPreserveHost On
    ProxyPass / http://localhost:8081/
    ProxyPassReverse / http://localhost:8081/
    RequestHeader set X-Forwarded-Proto "https"
</VirtualHost>

<VirtualHost *:443>
    ServerName api.liturgicalcalendar.com
    SSLEngine on
    SSLCertificateFile /etc/letsencrypt/live/api.liturgicalcalendar.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/api.liturgicalcalendar.com/privkey.pem

    ProxyPreserveHost On
    ProxyPass / http://localhost:8000/
    ProxyPassReverse / http://localhost:8000/
    RequestHeader set X-Forwarded-Proto "https"
</VirtualHost>

<VirtualHost *:80>
    ServerName auth.liturgicalcalendar.com
    Redirect permanent / https://auth.liturgicalcalendar.com/
</VirtualHost>

<VirtualHost *:80>
    ServerName login.liturgicalcalendar.com
    Redirect permanent / https://login.liturgicalcalendar.com/
</VirtualHost>

<VirtualHost *:80>
    ServerName api.liturgicalcalendar.com
    Redirect permanent / https://api.liturgicalcalendar.com/
</VirtualHost>

Step 6: Update API Environment

Create or update the API .env.local for production:

APP_ENV=production

# Zitadel - use production domain
ZITADEL_ISSUER=https://auth.liturgicalcalendar.com
ZITADEL_CLIENT_ID=<from-zitadel-console>
ZITADEL_PROJECT_ID=<from-zitadel-console>
ZITADEL_MACHINE_TOKEN=<generated-pat>

# Database
DB_HOST=localhost
DB_PORT=5432
DB_NAME=litcal
DB_USER=litcal
DB_PASSWORD=<generated-password>

# JWT
JWT_SECRET=<generated-64-char-hex>
JWT_ALGORITHM=HS256
JWT_EXPIRY=3600
JWT_REFRESH_EXPIRY=604800

# Security
CORS_ALLOWED_ORIGINS=https://litcal.johnromanodorazio.com,https://www.liturgicalcalendar.com
HTTPS_ENFORCEMENT=true
ALLOW_ENV_ADMIN_FALLBACK=false

# Admin fallback (keep hash set but fallback disabled)
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH=<argon2id-hash>

# Rate limiting
RATE_LIMIT_LOGIN_ATTEMPTS=5
RATE_LIMIT_LOGIN_WINDOW=900
RATE_LIMIT_STORAGE_PATH=/var/lib/litcal/rate_limits

Step 7: Run Migrations

Apply the database migrations to the litcal database:

for f in LiturgicalCalendarAPI/migrations/*.sql; do
    docker compose exec -T db psql -U litcal -d litcal < "$f"
done

Step 8: Configure Zitadel Project

After Zitadel is running on the production domain:

  1. Log in to the Console at https://auth.liturgicalcalendar.com/ui/console
  2. Change the default admin password immediately
  3. Create the project, roles, and applications as described in Infrastructure Setup
  4. Copy the generated client IDs and machine token into the API .env.local
  5. Update the frontend application's redirect URIs to use production domains
  6. Restart the API to pick up the new environment variables

Step 9: Verify

# Check Zitadel is healthy
curl -s https://auth.liturgicalcalendar.com/healthz

# Check OIDC discovery
curl -s https://auth.liturgicalcalendar.com/.well-known/openid-configuration | jq .issuer

# Check API auth endpoint
curl -s https://api.liturgicalcalendar.com/auth/me

Rollout Strategy

The OIDC integration is designed for incremental rollout. The OidcAvailabilityMiddleware returns HTTP 503 when Zitadel is not configured, so existing public API routes are completely unaffected:

  1. Phase 1: Deploy Zitadel + PostgreSQL, configure the API env vars. Auth endpoints become available but nothing breaks for existing users.
  2. Phase 2: Create your admin account in Zitadel, test the auth flow end-to-end.
  3. Phase 3: Build and deploy frontend login integration and admin UI.
  4. Phase 4: Wire in ApiKeyMiddleware and rate limiting once developers start registering applications.

Backup Strategy

Zitadel Database

The zitadel database contains all user accounts, organizations, projects, roles, and authentication data. Back it up regularly:

docker compose exec db pg_dump -U postgres zitadel > zitadel_backup_$(date +%Y%m%d).sql

Application Database

The litcal database contains role requests, permissions, applications, API keys, and audit logs:

docker compose exec db pg_dump -U litcal litcal > litcal_backup_$(date +%Y%m%d).sql

Automated Backups

Set up a cron job for daily backups:

0 2 * * * docker compose -f /path/to/docker-compose.yml exec -T db pg_dump -U postgres zitadel | gzip > /backups/zitadel_$(date +\%Y\%m\%d).sql.gz
0 2 * * * docker compose -f /path/to/docker-compose.yml exec -T db pg_dump -U litcal litcal | gzip > /backups/litcal_$(date +\%Y\%m\%d).sql.gz

Clone this wiki locally