Hiding the Keycloak admin console from the internet with an SSH tunnel
For about a month, the Keycloak admin console behind my site was one HTTPS request away from anyone on the internet. Not by accident: in August I decided that a tunnel was too much friction for a single operator, and that a strong password plus brute-force protection was enough. In September I reversed that decision in the repository. On 9 October I finally got it running on the box. This post is about why the reversal was right, how the tunnel works, and the small details that make it either work or lock you out.
What was exposed, and why the password was not enough
Keycloak sits on auth.zakaria.lu and brokers LinkedIn sign-in for every host of the site. Traefik routed the whole hostname to it with a plain Host() rule, with no path predicate. That published the entire Keycloak path space, including /admin/master/console/ (a full-privilege login) and /realms/master/protocol/openid-connect/token.
The August reasoning was: a long random admin password, brute-force protection on the master realm, and a rate limit at Traefik. Three things made me revisit it:
- Keycloak creates the
masterrealm with brute-force protection off. My application realm turns it on; master does not, unless someone remembers to enable it by hand. - The bootstrap admin account lives in the database. Removing
KC_BOOTSTRAP_ADMIN_*from.envdeletes a line from a file, not the account. - A master-realm admin can create a user with my email address. On this site that is a direct path to the owner-only analytics, the blog admin and my private FOCUS board.
Then the deciding question: does anything legitimate need those paths from outside? No. The application only touches /realms/cvnext/**, the account console is /realms/cvnext/account, and kcadm runs inside the container against localhost. When nothing needs a door, the right lock is no door.
Closing the public door at the edge
Traefik has no "deny" middleware, so I used the same idiom as my PHP-probe block: an IP allowlist whose only entry nobody can ever be in.
- traefik.http.routers.keycloak-admin-block.rule=Host(`${KEYCLOAK_DOMAIN}`) && (PathPrefix(`/admin`) || PathPrefix(`/realms/master`))
- traefik.http.routers.keycloak-admin-block.priority=200
- traefik.http.routers.keycloak-admin-block.middlewares=keycloak-deny
- traefik.http.middlewares.keycloak-deny.ipallowlist.sourcerange=192.0.2.1/32
192.0.2.1 is in TEST-NET-1 (RFC 5737), reserved for documentation and never routed, so the allowlist can never start letting someone in. Priority 200 beats the main Keycloak router (50), so the request gets a 403 before Keycloak ever sees it. I gave this router its own middleware rather than reusing a shared deny, so it keeps working if another service is down.

Opening a private door: the tunnel
The admin console still has to be reachable by me. Keycloak's port is published on the box's loopback only:
ports:
- "127.0.0.1:8080:8080"
Binding to 127.0.0.1 means the port exists only inside the box's own network namespace. Nothing off the box can connect to it, and the firewall is not even involved. From my laptop, I forward a local port over SSH:
ssh -N -o ExitOnForwardFailure=yes -L 127.0.0.1:9090:localhost:8080 root@<box>
Then I open http://localhost:9090/admin/master/console/. SSH to the box is key-only, so the admin console now sits behind possession of a private key instead of a password typed into a public form.
Three details that decide whether it works
The localhost in -L is resolved on the box. An earlier version of my own runbook said -L 8080:keycloak:8080. That cannot work: sshd runs on the host, outside the Docker network, so it cannot resolve the compose service name. That is exactly why the loopback port has to exist.
Keycloak builds absolute URLs. Without extra configuration, opening the console through the tunnel redirects to https://auth.zakaria.lu/admin/..., which my own deny router answers with a 403. You lock yourself out with your own protection, and it looks like the block is broken. The fix is one variable:
- KC_HOSTNAME_ADMIN=${KEYCLOAK_ADMIN_URL:-http://localhost:9090}
It pins only the admin URLs to the tunnel origin. The realm issuer still comes from KC_HOSTNAME, so token validation for the application does not change. It also means the local port must be 9090 (I chose it because 8080 is usually my local dev backend), and the browser must use localhost, not 127.0.0.1.
Use the full console path. Through the tunnel, /admin/ returns 404 on this version while /admin/master/console/ returns 200, so a quick check of the short URL looks like a broken tunnel.

Why it took a month to go live
The September commit was correct, and production did not get it. My CD pipeline ships only the application image. The docker-compose.yml file, where these Traefik labels and ports live, needs a manual sync to the box. A drift check runs on every deploy, but it is deliberately non-blocking: it writes a warning to the job summary. The warning was there; nothing made anyone act on it.
When I synced the file on 9 October, it also carried a second unapplied change: a Docker socket proxy for Traefik, with a read-only root filesystem the image cannot start on. All hosts returned 404 for about two minutes until I restored the backup the sync script had made, fixed the proxy, and applied it again. The lesson is the same one twice: a security fix in Git is a proposal until you verify it from the outside, and a config check only proves the file parses, not that the containers start.
Making the secure path the easy one
The August argument against the tunnel was friction, and it was a fair point. So I removed the friction instead of the protection. I now have a small Claude Code skill, /keycloak-cvnext, that checks port 9090, opens the tunnel in a background shell with ExitOnForwardFailure and keep-alives, proves the console answers, and opens the browser. It is explicit-only: the assistant can never decide on its own to open a door to the master realm.
If you run Keycloak behind a reverse proxy, check whether your proxy rule has a path predicate. If it does not, your admin console is published too.