quay.io/keycloak/keycloak with PostgreSQL backend, requiring separate build and start --optimized phases for production deployments.docker compose up -d with a compose file defining keycloak + postgres services and KC_ environment variables.start-dev in production -- it disables HTTPS, uses an in-memory H2 database, and enables insecure defaults that will lose all data on restart.quay.io/keycloak/keycloak:26.0. Requires Docker Compose V2+.start-dev in production -- it disables HTTPS, uses H2 in-memory DB, and enables insecure defaults--optimized flag with a pre-built image in production to avoid runtime build overhead on every container startKC_BOOTSTRAP_ADMIN_USERNAME/KC_BOOTSTRAP_ADMIN_PASSWORD -- the older KEYCLOAK_ADMIN variables are deprecatedKC_PROXY_HEADERS=xforwarded when behind a TLS-terminating reverse proxy| Service | Image | Ports | Volumes | Key Env |
|---|---|---|---|---|
| keycloak | quay.io/keycloak/keycloak:26.0 | 8080:8080 (dev), 8443:8443 (prod) | ./realm-export:/opt/keycloak/data/import | KC_DB=postgres |
| keycloak (health) | (same) | 9000:9000 | (none) | KC_HEALTH_ENABLED=true |
| postgres | postgres:16-alpine | 5432:5432 | pgdata:/var/lib/postgresql/data | POSTGRES_DB=keycloak |
| Variable | Purpose | Dev Default | Prod Recommendation |
|---|---|---|---|
KC_BOOTSTRAP_ADMIN_USERNAME | Initial admin username | admin | Set via secret |
KC_BOOTSTRAP_ADMIN_PASSWORD | Initial admin password | admin | Set via secret |
KC_DB | Database vendor | dev-file (H2) | postgres |
KC_DB_URL | JDBC connection URL | (auto) | jdbc:postgresql://postgres:5432/keycloak |
KC_DB_USERNAME | Database user | (auto) | keycloak |
KC_DB_PASSWORD | Database password | (auto) | Set via secret |
KC_HOSTNAME | Public-facing hostname | (none) | https://auth.example.com |
KC_PROXY_HEADERS | Proxy header forwarding | (none) | xforwarded |
KC_HTTP_ENABLED | Allow plain HTTP | true | true (behind proxy) or false |
KC_HEALTH_ENABLED | Health check endpoints | false | true |
KC_METRICS_ENABLED | Prometheus metrics | false | true |
KC_LOG_LEVEL | Logging verbosity | info | info or warn |
| Endpoint | Purpose | Expected Response |
|---|---|---|
/health | Overall health | {"status": "UP"} |
/health/ready | Readiness probe (K8s) | {"status": "UP"} |
/health/live | Liveness probe (K8s) | {"status": "UP"} |
/metrics | Prometheus metrics | Prometheus text format |
START: What deployment mode do you need?
├── Development / local testing?
│ ├── YES → Use start-dev with H2 (no database service needed)
│ └── NO ↓
├── Production single-node?
│ ├── YES → Use optimized build + PostgreSQL + reverse proxy
│ └── NO ↓
├── Production cluster (HA)?
│ ├── YES → Add KC_CACHE=ispn, open JGroups ports (7800), use shared DB
│ └── NO ↓
├── Need realm pre-configuration?
│ ├── YES → Mount realm JSON to /opt/keycloak/data/import + --import-realm
│ └── NO ↓
├── Behind reverse proxy (Nginx/Traefik/Caddy)?
│ ├── YES → Set KC_PROXY_HEADERS=xforwarded + KC_HTTP_ENABLED=true
│ └── NO ↓
└── DEFAULT → Production Compose with direct TLS termination on Keycloak
Start with a minimal development setup using start-dev mode. This uses Keycloak's built-in H2 database and disables HTTPS for local development. [src1]
# docker-compose.dev.yml -- Development only, DO NOT use in production
services:
keycloak:
image: quay.io/keycloak/keycloak:26.0
command: start-dev
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
ports:
- "8080:8080"
Verify: curl -s http://localhost:8080/realms/master | jq .realm → expected: "master"
Production requires an external database, optimized builds, and proper hostname configuration. [src2]
# docker-compose.yml -- Production
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak"]
interval: 10s
timeout: 5s
retries: 5
keycloak:
image: quay.io/keycloak/keycloak:26.0
command: start --optimized
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN_USER}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${DB_PASSWORD}
KC_HOSTNAME: https://auth.example.com
KC_PROXY_HEADERS: xforwarded
KC_HTTP_ENABLED: "true"
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
ports:
- "8080:8080"
- "9000:9000"
depends_on:
postgres:
condition: service_healthy
deploy:
resources:
limits:
memory: 2G
reservations:
memory: 750M
volumes:
pgdata:
Verify: curl -s http://localhost:9000/health/ready | jq .status → expected: "UP"
When running behind a TLS-terminating reverse proxy, set KC_PROXY_HEADERS=xforwarded and KC_HTTP_ENABLED=true. The proxy must forward X-Forwarded-* headers. [src5]
upstream keycloak {
server keycloak:8080;
}
server {
listen 443 ssl http2;
server_name auth.example.com;
ssl_certificate /etc/ssl/certs/fullchain.pem;
ssl_certificate_key /etc/ssl/private/privkey.pem;
location / {
proxy_pass http://keycloak;
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_set_header X-Forwarded-Port $server_port;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
}
Verify: curl -sI https://auth.example.com/realms/master → expected: HTTP/2 200
Export your realm configuration and mount it for automatic import on container startup. [src3]
# Add to keycloak service
services:
keycloak:
command: start --optimized --import-realm
volumes:
- ./realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
# Export realm from running container
docker exec keycloak /opt/keycloak/bin/kc.sh export \
--dir /opt/keycloak/data/export \
--realm myrealm \
--users realm_file
Verify: curl -s http://localhost:8080/realms/myrealm | jq .realm → expected: "myrealm"
Create a multi-stage Dockerfile that runs the build step at image build time to reduce startup time from 30-60s to under 5s. [src1]
# Dockerfile.keycloak
FROM quay.io/keycloak/keycloak:26.0 AS builder
ENV KC_DB=postgres
ENV KC_HEALTH_ENABLED=true
ENV KC_METRICS_ENABLED=true
RUN /opt/keycloak/bin/kc.sh build
FROM quay.io/keycloak/keycloak:26.0
COPY --from=builder /opt/keycloak/ /opt/keycloak/
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
Verify: Container starts in <5 seconds. Check: docker compose logs keycloak | grep "started in"
Mount custom themes or bake them into the image for branded login/registration pages. [src4]
# Volume mount (development)
services:
keycloak:
volumes:
- ./themes/my-theme:/opt/keycloak/themes/my-theme:ro
Verify: Admin Console → Realm Settings → Themes → select "my-theme" from dropdown
Full script: production-docker-compose.yml (62 lines)
# Production Keycloak + PostgreSQL + Nginx
# Usage: DB_PASSWORD=secret KC_ADMIN_USER=admin KC_ADMIN_PASSWORD=secret docker compose up -d
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak"]
interval: 10s
retries: 5
# ... (see full script)
Full script: realm-export-example.json (48 lines)
{
"realm": "myapp",
"enabled": true,
"clients": [{
"clientId": "myapp-frontend",
"enabled": true,
"publicClient": true,
"redirectUris": ["http://localhost:3000/*"],
"webOrigins": ["http://localhost:3000"],
"protocol": "openid-connect"
}]
}
#!/usr/bin/env bash
# Input: Keycloak health endpoint URL
# Output: Exit 0 if ready, exit 1 if not
KC_HEALTH_URL="${KC_HEALTH_URL:-http://localhost:9000/health/ready}"
MAX_RETRIES=30
RETRY_INTERVAL=2
for i in $(seq 1 "$MAX_RETRIES"); do
status=$(curl -sf "$KC_HEALTH_URL" | jq -r '.status' 2>/dev/null)
if [ "$status" = "UP" ]; then
echo "Keycloak is ready (attempt $i)"
exit 0
fi
echo "Waiting for Keycloak... ($i/$MAX_RETRIES)"
sleep "$RETRY_INTERVAL"
done
echo "ERROR: Keycloak did not become ready in time"
exit 1
# .env -- Keycloak Docker Compose environment variables
# NEVER commit this file to version control
DB_PASSWORD=change_me_to_a_strong_password
KC_ADMIN_USER=admin
KC_ADMIN_PASSWORD=change_me_to_a_strong_password
KC_HOSTNAME=https://auth.example.com
# BAD -- start-dev disables HTTPS, uses H2 in-memory DB, enables insecure defaults
services:
keycloak:
image: quay.io/keycloak/keycloak:26.0
command: start-dev
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
# GOOD -- production mode with PostgreSQL and optimized startup
services:
keycloak:
image: quay.io/keycloak/keycloak:26.0
command: start --optimized
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN_USER}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
# BAD -- KEYCLOAK_ADMIN/KEYCLOAK_ADMIN_PASSWORD deprecated in Keycloak 26+
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
# GOOD -- KC_BOOTSTRAP_ADMIN_ is the current standard (Keycloak 26+)
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN_USER}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
# BAD -- Keycloak may start before PostgreSQL is ready
services:
postgres:
image: postgres:16-alpine
keycloak:
image: quay.io/keycloak/keycloak:26.0
depends_on:
- postgres
# GOOD -- Keycloak waits for PostgreSQL readiness + memory limits
services:
postgres:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak"]
interval: 10s
retries: 5
keycloak:
image: quay.io/keycloak/keycloak:26.0
depends_on:
postgres:
condition: service_healthy
deploy:
resources:
limits:
memory: 2G
# BAD -- secrets in plain text (will be committed to git)
environment:
KC_DB_PASSWORD: my_super_secret_password
KC_BOOTSTRAP_ADMIN_PASSWORD: admin123
# GOOD -- secrets from .env file (add to .gitignore)
environment:
KC_DB_PASSWORD: ${DB_PASSWORD}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
start without a prior build step causes Keycloak to rebuild its configuration at every container startup. Fix: Use a multi-stage Dockerfile with kc.sh build or always pass --optimized. [src1]start-dev uses an in-memory H2 database. All realms, users, and clients are lost on restart. Fix: Always configure KC_DB=postgres with a persistent volume. [src2]KC_BOOTSTRAP_ADMIN_* only creates the admin user on initial startup. Changing them later has no effect. Fix: Use Admin Console or Admin CLI to change credentials after setup. [src1]"secret": "your-secret" to client entries or use the Admin REST API. [src3]502 Bad Gateway during OIDC token exchanges with large JWTs. Fix: Set proxy_buffer_size 128k and proxy_buffers 4 256k. [src5]/health/ready. [src1]KC_HOSTNAME to a value that does not match the proxy hostname causes infinite redirects. Fix: Ensure KC_HOSTNAME exactly matches the external URL including protocol. [src5]# Check if Keycloak is healthy
curl -sf http://localhost:9000/health/ready | jq .
# View Keycloak startup logs
docker compose logs keycloak --tail=50
# Test OIDC discovery endpoint
curl -s http://localhost:8080/realms/master/.well-known/openid-configuration | jq .
# Export realm from running instance
docker exec keycloak /opt/keycloak/bin/kc.sh export \
--dir /opt/keycloak/data/export --realm master
# Check PostgreSQL is accepting connections
docker exec postgres pg_isready -U keycloak
# Inspect Keycloak container environment
docker exec keycloak env | grep KC_
# Check resource usage
docker stats keycloak postgres --no-stream
# Verify TLS certificate (production)
openssl s_client -connect auth.example.com:443 -servername auth.example.com </dev/null 2>/dev/null | openssl x509 -noout -dates
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| 26.x (2024-2025) | Current | KC_BOOTSTRAP_ADMIN_* replaces KEYCLOAK_ADMIN_*; removed legacy WildFly config | Update env vars; use Quarkus-native config |
| 24.x (2024) | Maintained | Hostname V2 default; KC_PROXY deprecated for KC_PROXY_HEADERS | Replace KC_PROXY=edge with KC_PROXY_HEADERS=xforwarded |
| 22.x-23.x (2023-2024) | EOL | Persistent user sessions enabled by default | Review session storage configuration |
| 21.x (2023) | EOL | WildFly distribution removed | Migrate to Quarkus distribution |
| 17.x-20.x (2022-2023) | EOL | Quarkus becomes default; WildFly deprecated | See migration guide |
| Pre-17 (WildFly) | EOL | Completely different config format | Full migration required |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Need self-hosted OIDC/SAML identity provider | Simple API key authentication is sufficient | API gateway with key management |
| Require fine-grained authorization (RBAC, ABAC) | Only need social login (Google, GitHub) | Firebase Auth, Supabase Auth |
| Multi-tenant application needing realm isolation | Single-tenant app with basic auth | Simple session-based auth |
| On-premise deployment required by compliance | Cloud-native and want zero infra management | Auth0, Okta, AWS Cognito |
| Need LDAP/AD federation with modern OIDC frontend | Already using cloud IAM (Azure AD, Google Workspace) | Direct OIDC integration with cloud provider |
build step that bakes database driver selection, feature flags, and cache configuration into the binary -- changing these at runtime requires rebuilding--import-realm flag processes files in /opt/keycloak/data/import only on first startup or when the realm does not exist -- it will NOT overwrite existing realmsKC_CACHE=ispn with JGroups discovery and a shared database, or deploy on Kubernetes