Production-ready Keycloak deployment with automatic TLS (Caddy + Let's Encrypt). No certbot, no renewal cron, no manual cert copying.
Example domains:
auth.allspicetech.com.au— public authenticationauth-admin.allspicetech.com.au— admin console (IP-restricted)
- Keycloak 26.1 on PostgreSQL 16
- Caddy reverse proxy — automatic Let's Encrypt, HTTP→HTTPS redirect, security headers
- IP-restricted admin console on a separate subdomain
- Branded landing page at the public root
- Per-realm custom themes
Internet
↓
┌──────────────────────────┐
│ Caddy │ TLS termination, auto Let's Encrypt,
│ (ports 80 / 443) │ security headers, admin IP allow-list
└─────────────┬────────────┘
│ http
┌─────────────▼────────────┐
│ Keycloak │ OIDC / OAuth2 / SAML
│ (internal 8080) │
└─────────────┬────────────┘
│
┌─────────────▼────────────┐
│ PostgreSQL │
└──────────────────────────┘
- Ubuntu 22.04+ server (root access)
- Static public IPv4
- DNS access for two A records
- Ports 22, 80, 443 reachable from the internet
Add two A records pointing at the server's public IP:
| Name | Type | Value |
|---|---|---|
auth |
A | SERVER_IP |
auth-admin |
A | SERVER_IP |
Verify before starting:
nslookup auth.yourdomain.com
nslookup auth-admin.yourdomain.comBoth should return the server IP. Caddy will fail to issue certs if DNS isn't live yet.
scp -r . root@SERVER_IP:/opt/keycloak
ssh root@SERVER_IP
cd /opt/keycloak
./setup.shsetup.sh installs Docker + Compose, seeds .env, and optionally configures UFW.
nano .envRequired:
AUTH_HOSTNAME,ADMIN_HOSTNAME— bare hostnames (nohttps://)ACME_EMAIL— Let's Encrypt sends expiry notices hereADMIN_ALLOWED_IPS— space-separated IPs/CIDRs allowed on the admin subdomain. Get your IP withcurl ifconfig.me.KEYCLOAK_ADMIN_PASSWORD,POSTGRES_PASSWORD— useopenssl rand -base64 32
docker compose up -d
docker compose logs -f caddyOn first request to either hostname, Caddy requests TLS certificates from Let's Encrypt. Requirements:
- DNS resolves to this server
- Port 80 reachable from the internet (ACME HTTP-01 challenge)
curl -I https://auth.yourdomain.com/ # 200, with HSTS header
curl -I https://auth-admin.yourdomain.com/ # 403 unless your IP is allowedOpen in browser:
- Landing page:
https://auth.yourdomain.com - Admin console:
https://auth-admin.yourdomain.com(from an allowed IP)
Login: user admin, password from .env.
- Admin console → top-left dropdown → Create realm
- Name it after the client (e.g.
boxe) - Realm settings → Themes → Login Theme →
starky(shipped by the Keycloakify JAR baked into the image) - Clients → Create client → OIDC → set redirect URIs → copy client secret
Integration URL for your app:
https://auth.yourdomain.com/realms/<realm>/protocol/openid-connect/auth
The login theme is a Keycloakify React project at ./keycloak-theme/.
It compiles into a provider JAR that the Dockerfile copies into
/opt/keycloak/providers/ before kc.sh build. No runtime bind-mount —
everything is baked into the image.
Local preview (no Keycloak instance required):
cd keycloak-theme
npm install
npm run dev # Keycloakify mock runtime (Storybook-style)Build the JAR locally:
cd keycloak-theme
npm run build # requires JDK 17 on PATH (JAVA_HOME set)
# -> dist_keycloak/keycloak-theme-for-kc-all-other-versions.jarThe GitHub Actions pipeline does the JAR build and then docker build, so
local builds are only for iteration. For full details see
docs/superpowers/specs/2026-04-22-keycloak-theme-design.md.
Theme caching — set KC_THEME_CACHING=false in .env only during live
debugging against a running Keycloak; keep it true in production.
| Task | Command |
|---|---|
| View logs | docker compose logs -f [service] |
| Restart a service | docker compose restart [service] |
| Backup DB | ./backup.sh |
| Upgrade Keycloak | bump KEYCLOAK_VERSION in .env, docker compose pull && up -d |
| Upgrade Caddy | docker compose pull caddy && docker compose up -d caddy |
| Reload Caddy config | docker compose restart caddy |
0 2 * * * cd /opt/keycloak && ./backup.sh >> /var/log/keycloak-backup.log 2>&1- DNS not pointing here yet:
dig +short auth.yourdomain.com - Port 80 blocked: test
curl http://auth.yourdomain.com/healthfrom another network - Caddy logs:
docker compose logs caddy— look for ACME errors - Don't delete the
caddy_datavolume — it holds Let's Encrypt account keys and issued certs
Your IP isn't in ADMIN_ALLOWED_IPS. Update .env, then:
docker compose up -d caddy # picks up env changesdocker compose logs keycloakUsually DB connectivity or heap pressure. Bump -Xmx in docker-compose.yml if the VM is small.
Keycloak echoes back hostnames from KC_HOSTNAME. If you browse directly to the server IP instead of the hostname, you'll see "URL is outside of the configured frontendUrl" — browse to the hostname instead.
-
.envfilled; not committed -
ADMIN_ALLOWED_IPSset (default127.0.0.1/32denies everyone) - Strong generated passwords (not defaults)
-
KEYCLOAK_ADMIN_USERchanged fromadmin - UFW enabled, only 22/80/443 open
- DNS resolves before first boot
-
backup.shscheduled via cron
/opt/keycloak/
├── docker-compose.yml
├── .env # configuration (not in git)
├── env.example
├── setup.sh # one-shot VPS bootstrap
├── backup.sh # pg_dump + retention
├── caddy/
│ ├── Caddyfile # reverse proxy + automatic TLS
│ └── landing.html # public landing page (/)
├── keycloak-theme/ # Keycloakify React project — builds provider JAR
├── themes/ # (legacy classic-theme slot — README only)
├── import/ # realm JSON (imported on first boot)
└── backups/ # pg_dump archives (gitignored)