Keycloak Docker Compose: From Development to Production
Last updated: March 2026
Docker Compose is the fastest way to run Keycloak locally and a viable option for small to medium production deployments. The configuration differs significantly between a quick development setup and a production-hardened deployment. This guide provides three complete, tested Docker Compose configurations that take you from local development through staging to production.
Each configuration is self-contained and ready to use. If you want to generate a customized configuration interactively, use the Keycloak Docker Compose Generator tool.
Development Setup
The development configuration prioritizes speed and simplicity. Keycloak starts in development mode with an embedded H2 database, no TLS, and permissive defaults. This is for local development only.
docker-compose.dev.yml
# docker-compose.dev.yml
# Quick-start development setup - NOT for production
services:
keycloak:
image: quay.io/keycloak/keycloak:26.0
container_name: keycloak-dev
command: start-dev
environment:
# Admin credentials
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
# Development mode settings
KC_HTTP_PORT: 8080
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
# Logging
KC_LOG_LEVEL: INFO
ports:
- "8080:8080"
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/9000 && echo -e 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 && cat <&3 | grep -q '200 OK'"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
Using the Development Setup
# Start Keycloak in development mode
docker compose -f docker-compose.dev.yml up -d
# Wait for health check to pass
docker compose -f docker-compose.dev.yml exec keycloak
bash -c 'until curl -sf http://localhost:9000/health/ready; do sleep 2; done'
# Access the Admin Console
echo "Admin Console: http://localhost:8080/admin"
echo "Username: admin"
echo "Password: admin"
This setup uses Keycloak’s embedded H2 database, which stores data in memory by default. Data is lost when the container is removed. This is intentional for development.
Importing Realms in Development
For development, you often want a pre-configured realm. Mount a realm export file:
services:
keycloak:
image: quay.io/keycloak/keycloak:26.0
command: start-dev --import-realm
volumes:
- ./realm-export.json:/opt/keycloak/data/import/realm-export.json
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
ports:
- "8080:8080"
You can generate a Keycloak realm configuration using the Keycloak Config Generator.
Staging Setup
The staging configuration adds PostgreSQL, health checks, persistent storage, and basic security hardening. This is suitable for testing and staging environments.
docker-compose.staging.yml
# docker-compose.staging.yml
# Staging environment with PostgreSQL and health checks
services:
postgres:
image: postgres:16-alpine
container_name: keycloak-postgres
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-keycloak}
POSTGRES_USER: ${POSTGRES_USER:-keycloak}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-keycloak} -d ${POSTGRES_DB:-keycloak}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- keycloak-network
keycloak:
image: quay.io/keycloak/keycloak:26.0
container_name: keycloak
restart: unless-stopped
command: start
depends_on:
postgres:
condition: service_healthy
environment:
# Database
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-keycloak}
KC_DB_USERNAME: ${POSTGRES_USER:-keycloak}
KC_DB_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
# Admin credentials
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN_USER:-admin}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:?Set KC_ADMIN_PASSWORD in .env}
# HTTP settings (behind a reverse proxy)
KC_HTTP_ENABLED: "true"
KC_PROXY_HEADERS: "xforwarded"
KC_HOSTNAME: ${KC_HOSTNAME:-auth.staging.example.com}
# Health and metrics
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
# Logging
KC_LOG_LEVEL: INFO
KC_LOG_CONSOLE_FORMAT: "%d{yyyy-MM-dd HH:mm:ss} %-5p [%c] %s%e%n"
ports:
- "8080:8080"
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/9000 && echo -e 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 && cat <&3 | grep -q '200 OK'"]
interval: 10s
timeout: 5s
retries: 10
start_period: 60s
networks:
- keycloak-network
volumes:
postgres_data:
driver: local
networks:
keycloak-network:
driver: bridge
.env File for Staging
# .env
POSTGRES_DB=keycloak
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=change-this-staging-password
KC_ADMIN_USER=admin
KC_ADMIN_PASSWORD=change-this-admin-password
KC_HOSTNAME=auth.staging.example.com
Using the Staging Setup
# Create .env file with your values
cp .env.example .env
# Edit .env with real passwords
# Start the stack
docker compose -f docker-compose.staging.yml up -d
# Check status
docker compose -f docker-compose.staging.yml ps
# View logs
docker compose -f docker-compose.staging.yml logs -f keycloak
# Access the Admin Console
echo "Admin Console: http://localhost:8080/admin"
Production Setup
The production configuration adds TLS termination with Traefik, Keycloak clustering, resource limits, backup automation, and security hardening. This is suitable for production workloads serving up to tens of thousands of users.
For larger deployments, Kubernetes is recommended. See our guide on deploying Keycloak in Kubernetes with ArgoCD.
docker-compose.prod.yml
# docker-compose.prod.yml
# Production setup with Traefik, TLS, clustering, and backups
services:
# --- Reverse Proxy ---
traefik:
image: traefik:v3.2
container_name: traefik
restart: always
command:
- "--api.dashboard=false"
- "--providers.docker=true"
- "--providers.docker.exposedByDefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--accesslog=true"
- "--accesslog.filepath=/var/log/traefik/access.log"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt_data:/letsencrypt
- traefik_logs:/var/log/traefik
networks:
- keycloak-network
# --- Database ---
postgres:
image: postgres:16-alpine
container_name: keycloak-postgres
restart: always
environment:
POSTGRES_DB: ${POSTGRES_DB:-keycloak}
POSTGRES_USER: ${POSTGRES_USER:-keycloak}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
# Performance tuning
POSTGRES_INITDB_ARGS: "--data-checksums"
command:
- "postgres"
- "-c"
- "max_connections=200"
- "-c"
- "shared_buffers=256MB"
- "-c"
- "effective_cache_size=768MB"
- "-c"
- "work_mem=4MB"
- "-c"
- "maintenance_work_mem=64MB"
- "-c"
- "wal_buffers=8MB"
- "-c"
- "checkpoint_completion_target=0.9"
- "-c"
- "random_page_cost=1.1"
- "-c"
- "log_min_duration_statement=1000"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-keycloak} -d ${POSTGRES_DB:-keycloak}"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 1G
cpus: '1.0'
reservations:
memory: 512M
cpus: '0.5'
networks:
- keycloak-network
# --- Keycloak Node 1 ---
keycloak-1:
image: quay.io/keycloak/keycloak:26.0
container_name: keycloak-1
restart: always
command: start
depends_on:
postgres:
condition: service_healthy
environment:
# Database
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-keycloak}
KC_DB_USERNAME: ${POSTGRES_USER:-keycloak}
KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
KC_DB_POOL_INITIAL_SIZE: 5
KC_DB_POOL_MIN_SIZE: 5
KC_DB_POOL_MAX_SIZE: 20
# Hostname
KC_HOSTNAME: ${KC_HOSTNAME:?Set KC_HOSTNAME}
KC_PROXY_HEADERS: "xforwarded"
KC_HTTP_ENABLED: "true"
# Clustering
KC_CACHE: ispn
KC_CACHE_STACK: tcp
JAVA_OPTS_KC_HEAP: "-XX:MaxRAMPercentage=70.0"
# Admin (only needed for initial setup)
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN_USER:-admin}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
# Health and metrics
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
# Logging
KC_LOG_LEVEL: INFO
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/9000 && echo -e 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 && cat <&3 | grep -q '200 OK'"]
interval: 15s
timeout: 5s
retries: 10
start_period: 120s
deploy:
resources:
limits:
memory: 1536M
cpus: '2.0'
reservations:
memory: 1G
cpus: '1.0'
labels:
- "traefik.enable=true"
- "traefik.http.routers.keycloak.rule=Host(`${KC_HOSTNAME}`)"
- "traefik.http.routers.keycloak.entrypoints=websecure"
- "traefik.http.routers.keycloak.tls.certresolver=letsencrypt"
- "traefik.http.services.keycloak.loadbalancer.server.port=8080"
- "traefik.http.services.keycloak.loadbalancer.sticky.cookie=true"
- "traefik.http.services.keycloak.loadbalancer.sticky.cookie.name=KC_ROUTE"
- "traefik.http.services.keycloak.loadbalancer.sticky.cookie.secure=true"
- "traefik.http.services.keycloak.loadbalancer.sticky.cookie.httpOnly=true"
- "traefik.http.services.keycloak.loadbalancer.healthcheck.path=/health/ready"
- "traefik.http.services.keycloak.loadbalancer.healthcheck.port=9000"
- "traefik.http.services.keycloak.loadbalancer.healthcheck.interval=10s"
networks:
- keycloak-network
# --- Keycloak Node 2 ---
keycloak-2:
image: quay.io/keycloak/keycloak:26.0
container_name: keycloak-2
restart: always
command: start
depends_on:
postgres:
condition: service_healthy
keycloak-1:
condition: service_healthy
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-keycloak}
KC_DB_USERNAME: ${POSTGRES_USER:-keycloak}
KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
KC_DB_POOL_INITIAL_SIZE: 5
KC_DB_POOL_MIN_SIZE: 5
KC_DB_POOL_MAX_SIZE: 20
KC_HOSTNAME: ${KC_HOSTNAME}
KC_PROXY_HEADERS: "xforwarded"
KC_HTTP_ENABLED: "true"
KC_CACHE: ispn
KC_CACHE_STACK: tcp
JAVA_OPTS_KC_HEAP: "-XX:MaxRAMPercentage=70.0"
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
KC_LOG_LEVEL: INFO
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/9000 && echo -e 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 && cat <&3 | grep -q '200 OK'"]
interval: 15s
timeout: 5s
retries: 10
start_period: 120s
deploy:
resources:
limits:
memory: 1536M
cpus: '2.0'
reservations:
memory: 1G
cpus: '1.0'
labels:
- "traefik.enable=true"
- "traefik.http.routers.keycloak.rule=Host(`${KC_HOSTNAME}`)"
- "traefik.http.routers.keycloak.entrypoints=websecure"
- "traefik.http.routers.keycloak.tls.certresolver=letsencrypt"
- "traefik.http.services.keycloak.loadbalancer.server.port=8080"
networks:
- keycloak-network
# --- Backup ---
backup:
image: postgres:16-alpine
container_name: keycloak-backup
restart: "no"
depends_on:
postgres:
condition: service_healthy
environment:
PGHOST: postgres
PGDATABASE: ${POSTGRES_DB:-keycloak}
PGUSER: ${POSTGRES_USER:-keycloak}
PGPASSWORD: ${POSTGRES_PASSWORD}
volumes:
- backup_data:/backups
- ./scripts/backup.sh:/backup.sh:ro
entrypoint: ["sh", "/backup.sh"]
profiles:
- backup
networks:
- keycloak-network
volumes:
postgres_data:
driver: local
letsencrypt_data:
driver: local
traefik_logs:
driver: local
backup_data:
driver: local
networks:
keycloak-network:
driver: bridge
.env File for Production
# .env.prod
POSTGRES_DB=keycloak
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=use-a-very-strong-randomly-generated-password-here
KC_ADMIN_USER=admin
KC_ADMIN_PASSWORD=use-another-very-strong-password-here
KC_HOSTNAME=auth.example.com
[email protected]
Backup Script
#!/bin/sh
# scripts/backup.sh
# Runs a PostgreSQL dump and retains the last 7 daily backups
set -e
BACKUP_DIR="/backups"
DATE=$(date +%Y-%m-%d_%H-%M-%S)
BACKUP_FILE="${BACKUP_DIR}/keycloak_${DATE}.sql.gz"
echo "[$(date)] Starting backup..."
# Create compressed backup
pg_dump --format=custom --compress=9
--file="${BACKUP_FILE}"
echo "[$(date)] Backup created: ${BACKUP_FILE}"
# Remove backups older than 7 days
find "${BACKUP_DIR}" -name "keycloak_*.sql.gz" -mtime +7 -delete
echo "[$(date)] Old backups cleaned up"
# List current backups
echo "[$(date)] Current backups:"
ls -lh "${BACKUP_DIR}"/keycloak_*.sql.gz 2>/dev/null || echo "No backups found"
Make the script executable:
chmod +x scripts/backup.sh
Running Backups
# Run a one-time backup
docker compose -f docker-compose.prod.yml --profile backup run --rm backup
# Schedule daily backups with cron
# Add to crontab: crontab -e
# 0 2 * * * cd /path/to/project && docker compose -f docker-compose.prod.yml --profile backup run --rm backup >> /var/log/keycloak-backup.log 2>&1
Restoring from Backup
# Restore a backup
docker compose -f docker-compose.prod.yml exec postgres
pg_restore --clean --if-exists
-U keycloak -d keycloak /backups/keycloak_2026-05-15_02-00-00.sql.gz
Production Security Hardening
Restrict Admin Console Access
In production, restrict access to the Admin Console by IP or VPN. With Traefik, add an IP whitelist middleware:
# Add to keycloak-1 labels
- "traefik.http.middlewares.admin-whitelist.ipwhitelist.sourcerange=10.0.0.0/8,172.16.0.0/12"
- "traefik.http.routers.keycloak-admin.rule=Host(`${KC_HOSTNAME}`) && PathPrefix(`/admin`)"
- "traefik.http.routers.keycloak-admin.middlewares=admin-whitelist"
For more on securing the admin console, see our guide on securing the Keycloak master realm and path-based IP restriction for the admin console.
Security Headers
Add security headers via Traefik:
# Add to traefik command
- "--entrypoints.websecure.http.middlewares=security-headers"
# Add labels to keycloak service
- "traefik.http.middlewares.security-headers.headers.stsSeconds=31536000"
- "traefik.http.middlewares.security-headers.headers.stsIncludeSubdomains=true"
- "traefik.http.middlewares.security-headers.headers.stsPreload=true"
- "traefik.http.middlewares.security-headers.headers.contentTypeNosniff=true"
- "traefik.http.middlewares.security-headers.headers.frameDeny=true"
Adjust Default Keycloak Configurations
After deployment, review our guide on 8 default configurations to adjust on your Keycloak cluster and our Keycloak cluster configuration best practices.
Health Checks and Monitoring
Keycloak exposes health endpoints on port 9000 (separate from the main HTTP port). The Keycloak server guide on health checks covers all available endpoints:
# Liveness check
curl -s http://localhost:9000/health/live | jq
# Readiness check (includes database connectivity)
curl -s http://localhost:9000/health/ready | jq
# Prometheus metrics
curl -s http://localhost:9000/metrics
Key metrics to monitor:
| Metric | Alert Threshold |
|---|---|
keycloak_login_attempts_total |
Sudden spikes (brute force) |
keycloak_failed_login_attempts_total |
> 100/minute |
jvm_memory_used_bytes |
> 85% of limit |
db_connection_pool_active |
> 80% of max pool |
For monitoring and alerting with Keycloak, see our guides on authentication metrics and KPIs and Keycloak’s Insights dashboard.
Upgrading Keycloak
To upgrade Keycloak in Docker Compose:
# 1. Backup the database first
docker compose -f docker-compose.prod.yml --profile backup run --rm backup
# 2. Update the image tag in docker-compose.prod.yml
# Change: image: quay.io/keycloak/keycloak:26.0
# To: image: quay.io/keycloak/keycloak:26.1
# 3. Pull the new image
docker compose -f docker-compose.prod.yml pull keycloak-1 keycloak-2
# 4. Rolling restart (one node at a time)
docker compose -f docker-compose.prod.yml up -d keycloak-1
# Wait for keycloak-1 to be healthy
docker compose -f docker-compose.prod.yml up -d keycloak-2
For a comprehensive upgrade guide, see what is the best strategy to upgrade your Keycloak cluster.
When Docker Compose Is Not Enough
Docker Compose works well for:
- Development and testing environments
- Single-server production deployments up to ~50,000 users
- Small teams without Kubernetes expertise
Consider alternatives when:
- You need auto-scaling based on load
- You need zero-downtime deployments with rolling updates
- You operate across multiple availability zones
- You have regulatory requirements for high availability
For larger deployments, see our guide on deploying Keycloak in Kubernetes with ArgoCD.
For teams that do not want to manage any infrastructure, Skycloak’s managed Keycloak hosting handles all of this for you: clustering, TLS, backups, monitoring, and upgrades. Our SLA guarantees uptime, and our security practices cover everything from network isolation to encryption at rest. Check our pricing to see what is included.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.