-
-
Notifications
You must be signed in to change notification settings - Fork 33
Zitadel Production Deployment
This page covers the steps to deploy Zitadel and the RBAC system to a production server.
Run Zitadel, Login V2, and PostgreSQL on the same server as the API using Docker Compose. Simpler to manage, single server.
Run Zitadel on a dedicated host/VPS. Better isolation and scalability, but more infrastructure to manage.
Either way, the steps are similar.
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);"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 |
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.comOr use a wildcard certificate:
certbot certonly --manual --preferred-challenges dns -d "*.liturgicalcalendar.com"Key changes from the development configuration:
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: truelogin:
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: truedb:
image: postgres:17
environment:
POSTGRES_PASSWORD: <generated-password>Do not expose Adminer in production. Remove the adminer service entirely from the compose file, or restrict it to internal networks only.
# 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;
}<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>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_limitsApply the database migrations to the litcal database:
for f in migrations/*.sql; do
docker compose exec -T db psql -U litcal -d litcal < "$f"
doneAfter Zitadel is running on the production domain:
- Log in to the Console at
https://auth.liturgicalcalendar.com/ui/console - Change the default admin password immediately
- Create the project, roles, and applications as described in Infrastructure Setup
- Copy the generated client IDs and machine token into the API
.env.local - Update the frontend application's redirect URIs to use production domains
- Restart the API to pick up the new environment variables
# 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/meThe 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:
- Phase 1: Deploy Zitadel + PostgreSQL, configure the API env vars. Auth endpoints become available but nothing breaks for existing users.
- Phase 2: Create your admin account in Zitadel, test the auth flow end-to-end.
- Phase 3: Build and deploy frontend login integration and admin UI.
-
Phase 4: Wire in
ApiKeyMiddlewareand rate limiting once developers start registering applications.
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).sqlThe 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).sqlSet 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.gzFor Users
For Webmasters
For Liturgists
For Developers
For Contributors
Testing
Authentication & RBAC