8.5 Keycloak: the same job, in a container
Every tier does this one. If you're on Tier 1 and skipped the AD FS lessons, this is where your hands-on federation starts. If you're on Tier 2 and already have AD FS running, do it anyway: comparing the two is worth more than either alone, and Keycloak is what you'll meet outside Microsoft shops.
Keycloak is an open-source identity provider. It speaks SAML, OAuth 2.0 and OIDC, it runs in a container, and it needs no Windows server. It's also in wide production use, so this isn't a toy substitute for the real thing.
Everything you need is already on UBNT01: Docker from lesson 6.4, the compose pattern from 6.5, nginx from 6.7, and certificates from 7.6.
The stack
Keycloak keeps its data in a database, exactly as Gitea does. Create the project folder and secrets:
mkdir -p ~/docker/keycloak && cd ~/docker/keycloak
printf 'KC_ADMIN_PASSWORD=%s\n' "$(openssl rand -hex 16)" > .env
printf 'DB_PASSWORD=%s\n' "$(openssl rand -hex 16)" >> .env
chmod 600 .env
cat .env
Expect two long random values. Keep this file; the admin password is the only way into the console.
Write ~/docker/keycloak/compose.yaml:
services:
keycloak:
image: quay.io/keycloak/keycloak:26
restart: unless-stopped
# start-dev is deliberate for a lab: it skips the production
# hostname and TLS checks that would otherwise need configuring
# before you can see anything. nginx provides the real TLS.
command: start-dev --http-port=8080 --proxy-headers=xforwarded
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://db:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${DB_PASSWORD}
ports:
# Loopback only, as with every service since lesson 6.5.
- "127.0.0.1:8080:8080"
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- keycloak-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak"]
interval: 10s
timeout: 5s
retries: 5
volumes:
keycloak-db:
Two directives deserve a sentence.
--proxy-headers=xforwarded tells Keycloak it sits behind a reverse
proxy and should believe the X-Forwarded-* headers nginx sends. Without
it, Keycloak builds its redirect URLs from the address it can see, which
is localhost:8080, and every login bounces the browser somewhere that
doesn't exist. This is the single most common Keycloak-behind-nginx
failure.
start-dev skips production hostname and TLS strictness. For a lab
where nginx terminates TLS that's the right trade. A production deployment
uses start and configures those properly, which the Keycloak
documentation covers well.
Start it:
docker compose up -d
docker compose logs -f keycloak # watch for "Listening on", then Ctrl-C
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/
Expect 200 or 302. First boot runs database migrations and takes a
minute or two.
Give it a name and a certificate
Same two steps as Gitea in lessons 6.7 and 7.6, so this should feel familiar rather than new.
DNS. On DC01, or in your hypervisor's DNS if you're Tier 1 without a domain yet:
Add-DnsServerResourceRecordA -ZoneName "lab.internal" `
-Name "id" `
-IPv4Address "10.10.10.20"
That points id.lab.internal at UBNT01, where the container runs.
Certificate and vhost. Issue a certificate for id.lab.internal from
your own CA exactly as lesson 7.6 did, then create
/etc/nginx/sites-available/keycloak:
server {
listen 80;
server_name id.lab.internal;
include snippets/acme-challenge.conf;
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl http2;
server_name id.lab.internal;
ssl_certificate /etc/nginx/ssl/id.lab.internal/id.lab.internal.crt;
ssl_certificate_key /etc/nginx/ssl/id.lab.internal/id.lab.internal.key;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
# Keycloak builds every redirect URL from these. Get them wrong
# and logins bounce to localhost. This is what --proxy-headers
# in the compose file is trusting.
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;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
}
}
Enable it, and remember the order from lesson 6.7: test before reloading.
sudo ln -s /etc/nginx/sites-available/keycloak /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Browse to https://id.lab.internal. Padlock, no warning, Keycloak's
welcome page.
First login and a realm
Sign in to the administration console with admin and the password from
your .env.
Change nothing else until you've made a realm. A realm is an
isolated tenant: its own users, its own clients, its own signing keys.
Keycloak ships with one called master, and its only job should be
administering Keycloak itself.
Putting your applications in master is the beginner mistake, because it
gives every application's users a path to the system that controls all
your applications. It's the same instinct as the two accounts in lesson
5.6, one layer up.
Create one: the realm dropdown at the top left → Create realm → name
it lab → Create.
Then create a user in it: Users → Add user, username sokoth to match
the domain account, and set a password under the Credentials tab with
Temporary switched off.
The realm split is the principle again. master administers Keycloak;
lab holds the people who use your applications. A compromise of an
application account in lab reaches nothing in master.
The same reasoning produced the offline root in 7.2 and the read-only database connection in 6.9. Once you start seeing it, it's everywhere in well-built systems, and its absence is one of the fastest ways to spot a system that was assembled rather than designed.
Keycloak now knows who sokoth is. Next lesson makes Gitea trust it.