Skip to main content

Deploying Keycloak

Everything else in this book assumes a working Keycloak that applications can reach over HTTPS and that you can administer safely. This page covers the deployment and the next covers configuring realms inside it.

The deployment below is written as a standalone Keycloak instance. It terminates its own TLS and is reachable directly. If you run it behind a reverse proxy or web application firewall, as my environment does, see the notes in the config file and at the end of the page, because several settings change.

Installing Keycloak

The startup guide on the Keycloak website will get you started with a dev environment to be able to test drive it. Follow the instructions there to get started. In the appendix I have some base code you can deploy if you want to configure a test application.

When you are ready to move to production, below are the steps you need to follow. Keycloak should be considered critical infrastructure, so the best practice is to run it on its own host alongside no other services.

  1. Update DNS to point to your server (e.g., kc.example.com)
  2. Validate firewall ports 80 and 443 are open
  3. Create directories
sudo mkdir -p /opt/apps/keycloak/{nginx,webroot,themes,certbot-etc,certbot-var}
  1. Write .env file with database credentials, hostname, and bootstrap admin credentials
KC_ADMIN=<admin_username>
KC_ADMIN_PASSWORD=<admin_account_password>
POSTGRES_DB=keycloak
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=<poastgres_service_account_password>
KEYCLOAK_HOSTNAME=<https://kc.example.com>
  1. Write keycloak.conf (/opt/apps/keycloak/nginx/keycloak.conf)
server {
    listen 80;
    server_name kc.example.com;

    location /.well-known/acme-challenge/ {
        root /webroot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}
  1. Write docker-compose.yml
services:
  postgres:
    image: postgres:15                # Pinned major version. Postgres major upgrades require a
                                      # dump/restore, so this is deliberately not floating.
    restart: unless-stopped           # Restart on failure and on daemon start, but honor a manual stop.
    environment:
      POSTGRES_DB: ${POSTGRES_DB}         # DB name, from .env
      POSTGRES_USER: ${POSTGRES_USER}     # DB user, from .env
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}  # DB password, from .env
    volumes:
      - pgdata:/var/lib/postgresql/data   # Named volume: the inside container database files managed by docker
    networks:
      - keycloak_net                      # No published ports: reachable only from the compose network.

  keycloak:
    image: quay.io/keycloak/keycloak:26.6.4   # Pinned. Keycloak has breaking changes between
                                              # minor versions; :latest is an operational hazard.
    restart: unless-stopped
    command: start                    # Production mode. (start-dev disables TLS requirements and
                                      # is for local experimentation only.)
    depends_on:
      - postgres                      # Start ordering only. Does NOT wait for Postgres to be ready,
                                      # so Keycloak may need a restart cycle on first boot.
    environment:
      KC_DB: postgres                 # Database vendor.
      KC_DB_URL_HOST: postgres        # Hostname = the compose service name, resolved on keycloak_net.
      KC_DB_URL_DATABASE: ${POSTGRES_DB}
      KC_DB_USERNAME: ${POSTGRES_USER}
      KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
      KC_HOSTNAME: ${KEYCLOAK_HOSTNAME}   # The PUBLIC URL Keycloak believes it lives at. Used to build
                                          # the token issuer, the discovery document endpoints, and
                                          # redirect targets. Wrong value = clients get URLs they
                                          # can't reach, or issuer validation fails.
      KC_HTTP_ENABLED: "true"        # Keycloak serves plain HTTP internally; the nginx sidecar terminates TLS.
      KC_PROXY_HEADERS: xforwarded    # Trust X-Forwarded-* headers.

      KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN}                    # Created on FIRST START only, remove after
      KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}  # Created on FIRST START only, remove after

#    Uncomment while developing themes, comment out when finished
#      KC_SPI_THEME_STATIC_MAX_AGE: "-1"      # Disable static asset caching
#      KC_SPI_THEME_CACHE_THEMES: "false"     # Reload themes on each request
#      KC_SPI_THEME_CACHE_TEMPLATES: "false"  # Reload templates on each request

    ports:
      - "127.0.0.1:8080:8080"         # Bound to loopback: not reachable from the network, only from
                                      # the host itself.
    volumes:
      - /opt/apps/keycloak/themes/:/opt/keycloak/themes/:ro   # Custom login themes, read-only.
    networks:
      - keycloak_net
    logging:
      driver: "syslog"                # Ship container logs to host syslog...
      options:
        tag: "keycloak"               # ...tagged "keycloak", which is what the log-parsing and
                                      # alerting pipeline keys on.

  nginx:
    image: nginx:alpine               # Unpinned minor version; alpine tag floats.
    restart: unless-stopped
    ports:
      - "80:80"                       # HTTP redirect to HTTPS
      - "443:443"                     # Serves HTTPS
    volumes:
      - ./nginx/keycloak.conf:/etc/nginx/conf.d/default.conf  # Proxy config for this container.
      - /opt/apps/keycloak/certbot-etc:/etc/letsencrypt       # BIND mount (host path)
      - /opt/apps/keycloak/certbot-var:/var/lib/letsencrypt   # BIND mount (host path)
      - ./webroot:/webroot                                    # ACME challenge webroot, shared w/ certbot
    depends_on:
      - keycloak
    networks:
      - keycloak_net

  certbot:
    image: certbot/certbot            # Unpinned.
    volumes:
      - /opt/apps/keycloak/certbot-etc:/etc/letsencrypt
      - /opt/apps/keycloak/certbot-var:/var/lib/letsencrypt
      - ./webroot:/webroot                # Shared challenge webroot.
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --webroot --webroot-path=/webroot; sleep 12h & wait $${!}; done'"
                                      # Attempts renewal every 12h; certbot no-ops unless within 30 days of expiry.
    networks:
      - keycloak_net

volumes:
  pgdata:
  
networks:
  keycloak_net:                       # Private bridge network. Service names resolve as hostnames.
  1. Start everything but certbot
docker compose up -d postgres keycloak nginx
  1. Issue public certificate
docker compose run --rm --entrypoint certbot certbot certonly \
  --webroot --webroot-path=/webroot \
  -d kc.example.com \
  --email you@example.com --agree-tos --no-eff-email
  1. Add 443 serverblock to keycloak.conf
server {
    listen 443 ssl;
    server_name kc.example.com;

    ssl_certificate /etc/letsencrypt/live/kc.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/kc.example.com/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_ciphers HIGH:!aNULL:!MD5;

    location / {
        proxy_pass http://keycloak: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 https;
    }
}
  1. Restart containers
docker compose restart nginx
docker compose up -d
  1. Proceed with initial config

Notes

Replace kc.example.com with your actual site in the following places:

  • Your DNS site in step 1
  • The KEYCLOAK_HOSTNAME variable in step 4
  • The server_name in step 5
  • The -d switch in step 8
  • And three places in your keycloak.conf file in step 9

First login and locking down the admin account

  1. Browse to the Keycloak URL and sign in with the bootstrap administrator.
  2. Create a real administrative user with a strong, unique password stored in a password manager.
  3. I highly recommend you enable MFA on that account immediately. The Keycloak administrator can create clients, alter authentication flows, and grant themselves access to every downstream application. It is the most privileged account in the environment and should never be protected by a password alone.
  4. Remove the bootstrap credentials from the .env file and restart the containers.

Upgrades

Because the version is pinned, upgrades are a deliberate procedure rather than a background event. The sequence that has proven necessary:

  1. Back up the database first. pg_dump the Keycloak database to a file before anything else. Realms, users, clients, and mappers all live there; a failed migration without a backup is an environment-ending event.
  2. Back up configuration, including the compose file, the environment file, themes, and certificates.
  3. Read the upgrade notes for every version you are crossing, not just the target. Breaking changes accumulate across intermediate versions, and multi-version jumps sometimes need to be done in stages.
  4. Update the pinned tag, pull, and restart, watching the container logs as it comes up. Database migrations run at startup and their success or failure appears there.
  5. Test a real login end to end through at least one downstream application before considering the upgrade finished. Realm and client behavior can change subtly across versions in ways the container's own health check will not catch.

Keycloak's own upgrade documentation is the authority on breaking changes and is worth reading in full each time, not skimmed.

Backups

Two things must be captured for a restorable identity provider:

  • The database dump, which contains every realm, user, group, client, and mapper.
  • The configuration and secrets, meaning the compose file, environment file, certificates, and any custom themes.

A database backup without the environment file is not a restore, because the database credentials and admin bootstrap values live there. Both belong in the same backup routine as the rest of the environment.

Running behind a reverse proxy or WAF

My environment does not expose Keycloak directly. It sits behind a web application firewall that terminates public TLS and forwards to the Keycloak host over an internally authenticated connection. That arrangement changes several things above:

  • TLS terminates at the proxy, not at Keycloak. The public certificate lives on the proxy. Keycloak still needs to know its public URL through KC_HOSTNAME, because that is what it publishes in the discovery document and uses to build redirects, but it is no longer the component presenting the public certificate to browsers.
  • Keycloak must be told it is behind a proxy. Keycloak needs to trust the forwarded headers so it can construct correct URLs and see the real client address rather than the proxy's. Without this, redirects can be built with the internal hostname or the wrong scheme, and login loops or "invalid redirect" errors follow. The proxy must be configured to send the standard forwarded headers, and Keycloak configured to honor them.
  • The forwarded protocol header matters more than it looks. If the proxy does not tell Keycloak that the original request was HTTPS, Keycloak can build http:// URLs and browsers will refuse to follow them. This is one of the most common causes of a Keycloak deployment that works directly but breaks behind a proxy.
  • Certificate management moves. The public certificate is obtained and renewed on the proxy. Internally, the connection from the proxy to the Keycloak host is authenticated separately, which in this environment means mutual TLS with certificates from a private certificate authority.
  • Client IP visibility. Brute-force detection and logging are only as good as the address Keycloak sees. If the forwarded-for header is not passed and honored, every login attempt appears to come from the proxy, and per-address protections become meaningless.

A dedicated book on running a web application firewall in a small ecosystem is planned, covering the proxy configuration, request inspection, and the internal authentication between the proxy and its backends in detail. [Link to be added: WAF for Small Ecosystems]