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.
- Update DNS to point to your server (e.g., kc.example.com)
- Validate firewall ports 80 and 443 are open
- Create directories
sudo mkdir -p /opt/apps/keycloak/{nginx,webroot,themes,certbot-etc,certbot-var}
- 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>
- 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;
}
}
- Write docker-compose.yml (/opt/apps/keycloak/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.
- Start everything but certbot
docker compose up -d postgres keycloak nginx
- 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
- 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;
}
}
- Restart containers
docker compose restart nginx
docker compose up -d
- 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
- Browse to the Keycloak URL and sign in with the bootstrap administrator.
- Create a real administrative user with a strong, unique password stored in a password manager.
- 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.
- 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:
- Carefully review the patch notes - Make note of breaking changes, and it's worth to take a quick look at community pages to see if the patch has introduced any additional issues. There was one such issue involving a PostgreSQL startup crash loop for an upgrade that, thankfully, I found out about prior to upgrading, so I'm glad I checked.
- Back up your database and config - You can use the following commands to do so:
docker exec -t keycloak-postgres-1 pg_dump -U ${POSTGRES_USER} -d ${POSTGRES_DB} > keycloak_$(date +%F).sql
sudo tar -czvf kc_certbot_$(date +%F).tar.gz /opt/apps/keycloak/certbot-etc
sudo tar -czvf kc_nginx_$(date +%F).tar.gz /opt/apps/keycloak/nginx
sudo tar -czvf kc_themes_$(date +%F).tar.gz /opt/apps/keycloak/themes
sudo tar -czvf kc_config_$(date +%F).tar.gz /opt/apps/keycloak/docker-compose.yml .env
- Update docker-compose.yml as needed - Make any changes based on the patch notes; features which may have been added, modified, or deprecated and require new keys in the yml. Then change the image key to reflect the desired updated version.
- Pull upgrade and restart - Run the below, and then test the admin UI and a couple logout / in of your connected services to validate.
docker compose up -d --pull-always
Running behind a reverse proxy or WAF
In my environment, all my publically-publicly-facing URLs sit behind a Web Application Firewall (WAF). That arrangement changes several things:
- 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.
- 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 mTLS with certificates from a self-hosted private Certificate Authority.
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]
What changes
- DNS should point to the WAF, not your keycloak host
- Firewall rules depend on how your WAF is configured
- You can remove nginx, certbot-etc, and certbot-var from the folder list
- No changes
- Skip this step
- Remove the nginx and certbot service blocks
Only runRun docker compose up -d- Skip
thisstepsstep8-10, Skipyou'rethis stepSkip this stepdone!
What's new
- The WAF must send X-Forwarded-Proto https. Without it, Keycloak
builtsbuilds http URLs and you will get login loops. - The WAF must pass X-Forwarded-For. Without, Keycloak's brute-force detection sees every attempt as coming from the
waf,WAF, and per-address protection becomes 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]