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.

AKeycloak PostgreSQLrequires database,three aservices:

webserver,
  • The Keycloak identity provider container itself, which conntects to postgres and serves HTTP on port 8080 to the localhost only.
  • A postgres database which stores the realm, user, group, client, and mapper configs, and the active sessions. It can be reached only by the keycloak iteselfcontainer.
  • run
  • An nginx reverse proxy in containersfront with data and configuration onof the host so that backups and upgrades are straightforward. These three containers are required for standing up a production environment.

    The Keycloak container runspublishing the actualweb service

    UI
      and
    • Containerized,serving withthe aACME pinnedchallenge version.webroot certificates.

    Keycloak sometimesshould introducesbe breakingconsidered changescritical betweeninfrastructure, minorso versionsthe oftenbest enough that image:latestpractice is ato genuinerun operationalit hazard. The image tag should use an explicit image.

  • An external database, not the embedded one. Keycloak's development-mode database is not meant for real use. PostgreSQL inon its own container,host with itsno dataother persistedservices. on

    When theyou host,are isready theto minimumcontinue viablein productionproduction, setup.

  • all
  • Its own hostname over TLS. Applications and browsers bothyou need to reachdo theis identityupdate provideryour at.yml a stable HTTPS URL,file and thatrecreate URLyour ends up baked into every client's discovery configuration.
containers.

Compose file

services:
  postgres:
    image: postgres:1615                container_name:# keycloak-postgresPinned 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=keycloak${POSTGRES_DB}         -# POSTGRES_USER=keycloakDB -name, POSTGRES_PASSWORD=from .env
      POSTGRES_USER: ${POSTGRES_USER}     # DB user, from .env
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}  # DB password, from .env
    volumes:
      - /opt/apps/keycloak/postgres:pgdata:/var/lib/postgresql/data   restart:# unless-stoppedNamed volume: the inside container database files managed by keycloak
    networks:
      - keycloak_net                      # No published ports: reachable only from the compose network.

  keycloak:
    image: quay.io/keycloak/keycloak:26.5.76.4   # pinned;Pinned. neverKeycloak has breaking changes between
                                              # minor versions; :latest container_name:is keycloakan operational hazard.
    restart: unless-stopped
    command: start                    environment:# Production mode. (start-dev disables TLS requirements and
                                      # is for local experimentation only.)
    depends_on:
      - KC_DB=postgres                      -# KC_DB_URL=jdbc:postgresql://postgres:5432/keycloakStart -ordering KC_DB_USERNAME=keycloakonly. -Does KC_DB_PASSWORD=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: KC_HOSTNAME=https://auth.example.org${KEYCLOAK_HOSTNAME}   -# KC_HTTPS_CERTIFICATE_FILE=/etc/certs/fullchain.pemThe -PUBLIC KC_HTTPS_CERTIFICATE_KEY_FILE=/etc/certs/privkey.pemURL -Keycloak KC_BOOTSTRAP_ADMIN_USERNAME=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: "false"        # Use this setting if you are not behind another proxy or WAF
      KC_PROXY_HEADERS: xforwarded    # Trust X-Forwarded-* headers.

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

#    Uncomment for theme dev, comment for prod
#      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
                                              # All three: dev-only. Real performance cost in prod.
    ports:
      - "127.0.0.1:8080:8080"         # Bound to loopback: not reachable from the network, only from
                                      # the host itself. See note below about the two nginxes.
    volumes:
      - /opt/apps/keycloak/certs: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 on the host. Serves the ACME challenge path.
#      - "443:443"                     # HTTPS commented out: TLS is terminated elsewhere.
    volumes:
      - ./nginx/keycloak.conf:/etc/certs:ronginx/conf.d/default.conf  # Proxy config for this container.
      - /opt/apps/keycloak/themes:certbot-etc:/opt/keycloak/themesetc/letsencrypt       ports:# BIND mount (host path)
      - "443:8443"/opt/apps/keycloak/certbot-var:/var/lib/letsencrypt   # BIND mount (host path)
      - ./webroot:/webroot                                    # ACME challenge webroot, shared w/ certbot
    depends_on:
      - postgreskeycloak
    restart:networks:
      unless-stopped- keycloak_net

  certbot:
    image: certbot/certbot            # Unpinned.
    volumes:
      - certbot-etc:/etc/letsencrypt      # NAMED VOLUME (not the host path nginx uses!)
      - certbot-var:/var/lib/letsencrypt  # NAMED VOLUME (same mismatch)
      - ./webroot:/webroot                # Shared challenge webroot. This one DOES match nginx.
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do sleep 3600 & wait $${!}; done'"
                                      # Sleeps forever doing nothing. It's a parked container you
                                      # exec into to run certonly/renew manually. It does NOT
                                      # auto-renew on its own.
    networks:
      - keycloak_net

volumes:
  pgdata:                             # Database files.
  certbot-etc:                        # Declared and used by certbot...
  certbot-var:                        # ...but NOT by nginx. See note.

networks:
  keycloak_net:                       # Private bridge network. Service names resolve as hostnames.

Secrets come from an .env

Environment file beside the compose file, never inline:

POSTGRES_PASSWORD=REDACTED
KC_ADMIN=REDACTED<admin_username>
KC_ADMIN_PASSWORD=REDACTED<admin_account_password>
POSTGRES_DB=keycloak
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=<poastgres_service_account_password>
KEYCLOAK_HOSTNAME=<your_page_>

What each setting does, and why

Setting Why it's set this way
image: …:26.5.7 Pinned to an explicit version. Keycloak upgrades can require database migrations and can change realm or client behavior, so version changes are deliberate maintenance events, not something that happens because a container restarted.
command: start Production mode. The alternative, start-dev, disables TLS requirements and uses an ephemeral database. It is for local experimentation only and should never appear on a host that real users authenticate against.
KC_DB / KC_DB_URL / credentials Points Keycloak at PostgreSQL. The database holds every realm, user, client, and session, which makes it the single most important thing to back up.
KC_HOSTNAME The public URL Keycloak believes it lives at. This is not cosmetic: it is used to build the issuer value, the endpoints published in the discovery document, and the redirect targets. If it is wrong, clients receive URLs they cannot reach, or token issuer validation fails.
KC_HTTPS_CERTIFICATE_FILE / _KEY_FILE TLS served by Keycloak itself, in the standalone arrangement.
KC_BOOTSTRAP_ADMIN_* Creates the initial administrator on first startup only. Treat these as bootstrap credentials: use them once to log in, then create a proper administrator account with MFA, and remove these variables.
volumes: certs (ro) Certificates mounted read-only. The container needs to read them and has no reason to write them.
ports: 443:8443 Keycloak listens on 8443 inside the container; it is published on 443 on the host.

Certificates

In the standalone arrangement Keycloak needs a certificate for its own hostname. A containerized ACME client can obtain and renew it into the same directory the Keycloak container mounts:

docker run --rm \
  -v /opt/apps/keycloak/certbot-etc:/etc/letsencrypt \
  -v /opt/apps/keycloak/certbot-var:/var/lib/letsencrypt \
  certbot/certbot certonly --webroot \
  --webroot-path=/var/lib/letsencrypt \
  --email you@example.org --agree-tos --no-eff-email \
  -d auth.example.org

Renewal runs on a schedule:

1 0 5 * * docker run --rm -v /opt/apps/keycloak/certbot-etc:/etc/letsencrypt -v /opt/apps/keycloak/certbot-var:/var/lib/letsencrypt certbot/certbot renew --webroot --webroot-path /var/lib/letsencrypt -v

Keycloak reads its certificate at startup, so a renewal needs the container restarted to be picked up. Whatever automation renews the certificate should also restart the container, or a successfully renewed certificate will sit on disk unused while the running instance serves an expired one.

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. Enable multi-factor authentication 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 environment file and restart.

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

This 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]

Adapt this for…

Any self-hosted identity provider deployment. The specifics of the compose file are Keycloak's, but the decisions generalize: pin the version because identity providers break in ways that matter, use a real database and back it up as the crown jewels, make sure the service knows its own public URL, and protect the administrative account with multi-factor authentication before you do anything else with it. If the identity provider is compromised, every application behind it is compromised at the same moment, which is the entire reason the rest of this book works.