Keycloak Docker Compose: From Development to Production

Guilliano Molaire Guilliano Molaire Updated June 3, 2026 10 min read

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.

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman