Keycloak in Docker Compose

services: keycloak: image: quay.io/keycloak/keycloak:26.8 command: start-dev ports: - "127.0.0.1:8080:8080" environment: KC_BOOTSTRAP_ADMIN_USERNAME: admin KC_BOOTSTRAP_ADMIN_PASSWORD: admin

In Docker Compose, a Keycloak service needs command: start-dev, or start in production: the image has no default command, and without one kc.sh prints its usage and exits. The first admin comes from KC_BOOTSTRAP_ADMIN_USERNAME and KC_BOOTSTRAP_ADMIN_PASSWORD.

The docker run command, converted

The command from Keycloak’s “Get started with Docker” guide, with the release series pinned:

docker run -p 127.0.0.1:8080:8080 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:26.8 start-dev
services:
  keycloak:
    image: quay.io/keycloak/keycloak:26.8
    command: start-dev
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: admin

start-dev keeps realms and users in an H2 database under /opt/keycloak/data/h2, inside the container, so they’re gone with it. A volume at /opt/keycloak/data keeps them; Postgres is the setup to grow into.

With Postgres and a health check

services:
  postgres:
    image: postgres:18
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: keycloak-db-pw
    volumes:
      - keycloak-db:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h localhost -U keycloak -d keycloak"]
      interval: 5s
      timeout: 5s
      retries: 10
  keycloak:
    image: quay.io/keycloak/keycloak:26.8
    command: start-dev
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: admin
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: keycloak-db-pw
      KC_HEALTH_ENABLED: "true"
    healthcheck:
      test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/9000 && printf 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 60s
    depends_on:
      postgres:
        condition: service_healthy
volumes:
  keycloak-db:

KC_HEALTH_ENABLED turns on /health/ready on the management port, 9000, which answers 200 when Keycloak is ready and 503 when a readiness check, such as the database connection, fails. The image is kept small and ships no curl or wget, but it has bash, so the check writes the HTTP request to /dev/tcp itself and reads the status line. Port 9000 needs no ports entry: the check runs inside the container.

Gotchas

  • The bootstrap admin is temporary, as the log says (Created temporary admin user with username admin): create a permanent admin and delete it. It’s only created while the master realm has no admin, so changing KC_BOOTSTRAP_ADMIN_PASSWORD later does nothing.
  • KEYCLOAK_ADMIN and KEYCLOAK_ADMIN_PASSWORD from older guides still work in 26.8, with a warning: Environment variable 'KEYCLOAK_ADMIN' is deprecated, use 'KC_BOOTSTRAP_ADMIN_USERNAME' instead.
  • command: start is production mode. It stops with Key material not provided to setup HTTPS until it has a certificate, or KC_HTTP_ENABLED: "true" behind a proxy that terminates TLS, and then with hostname is not configured until KC_HOSTNAME is set.
  • docker compose down -v deletes keycloak-db, with every realm in it.

Try it in the docker run to Compose Converter

compose.yaml

1 service · everything carried over

Runs in your browser. Nothing you enter is sent anywhere.