Keycloak SSO: Self-Hosted Identity Provider for Your Homelab
Managing separate logins for Grafana, Portainer, Proxmox, Gitea, and every other service in your homelab is a security and usability nightmare. Centralise authentication with Keycloak — a battle-tested, open-source identity and access management platform that supports OpenID Connect (OIDC), SAML 2.0, and social login out of the box.
By the end of this project you will have a production-grade SSO layer running in Docker, backed by PostgreSQL, fronted by Traefik with automatic TLS, and already integrated with Grafana and Portainer as concrete examples you can extend to any OIDC-capable application.
Project Overview
What We're Building
┌──────────────────────────────────────────────────────────────────┐
│ Homelab SSO Architecture │
├──────────────────────────────────────────────────────────────────┤
│ │
│ Browser ──► Traefik (TLS termination / forward auth) │
│ │ │
│ ┌──────────▼───────────┐ │
│ │ Keycloak │ ◄─── PostgreSQL (state) │
│ │ auth.homelab.local │ │
│ │ │ │
│ │ Realm: homelab │ │
│ │ Clients: grafana, │ │
│ │ portainer, traefik │ │
│ └──────────┬───────────┘ │
│ │ OIDC / JWT │
│ ┌────────────┼────────────────┐ │
│ ▼ ▼ ▼ │
│ Grafana Portainer traefik-forward-auth │
│ (SSO login) (SSO login) (protects any app) │
│ │
└──────────────────────────────────────────────────────────────────┘Prerequisites
- Docker 24+ and Docker Compose v2
- A wildcard or per-service DNS record pointing to your Traefik host (e.g.
*.homelab.local→ your server IP) - Traefik v2/v3 already running as a reverse proxy (or follow the Traefik section below to add it)
- Ports 80 and 443 reachable from your browser
Resource Requirements
| Component | CPU | RAM | Disk |
|---|---|---|---|
| Keycloak | 0.5–2 cores | 512 MB – 2 GB | 200 MB image |
| PostgreSQL | 0.25 cores | 256 MB | 1 GB+ data |
Part 1: Docker Compose Setup
Step 1: Create the Project Directory
mkdir -p ~/keycloak/{data,postgres-data}
cd ~/keycloakStep 2: Write the Compose File
# docker-compose.yml
version: "3.9"
services:
postgres:
image: postgres:16-alpine
container_name: keycloak-db
restart: unless-stopped
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ./postgres-data:/var/lib/postgresql/data
networks:
- keycloak-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak"]
interval: 10s
timeout: 5s
retries: 5
keycloak:
image: quay.io/keycloak/keycloak:26.0
container_name: keycloak
restart: unless-stopped
command: start
depends_on:
postgres:
condition: service_healthy
environment:
# Database
KC_DB: postgres
KC_DB_URL_HOST: keycloak-db
KC_DB_URL_DATABASE: keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
# Hostname (must match what Traefik exposes)
KC_HOSTNAME: auth.homelab.local
KC_HOSTNAME_STRICT: "true"
KC_HOSTNAME_ADMIN: auth.homelab.local
# HTTP (TLS terminated by Traefik upstream)
KC_HTTP_ENABLED: "true"
KC_HTTP_PORT: "8080"
KC_HTTPS_REQUIRED: none
KC_PROXY_HEADERS: xforwarded
# Admin bootstrap (26.x naming — only used on first start)
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
# Performance & observability
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
JAVA_OPTS_APPEND: "-Xms256m -Xmx512m"
volumes:
- ./data:/opt/keycloak/data
networks:
- keycloak-internal
- proxy # shared Traefik network
labels:
- "traefik.enable=true"
- "traefik.http.routers.keycloak.rule=Host(`auth.homelab.local`)"
- "traefik.http.routers.keycloak.entrypoints=websecure"
- "traefik.http.routers.keycloak.tls=true"
- "traefik.http.routers.keycloak.tls.certresolver=letsencrypt"
- "traefik.http.services.keycloak.loadbalancer.server.port=8080"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:9000/health/ready || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 90s
networks:
keycloak-internal:
internal: true
proxy:
external: trueStep 3: Create the Environment File
# .env (never commit this file)
POSTGRES_PASSWORD=change_me_strong_db_pass
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=change_me_strong_admin_pass
# Generate a random cookie secret: openssl rand -base64 32chmod 600 .envStep 4: Start the Stack
docker compose up -d
# Watch Keycloak initialise (takes 60-90 seconds on first boot)
docker compose logs -f keycloakLook for: Keycloak 24.0.x on JVM ... started in 65.123s.
Part 2: Initial Keycloak Configuration
Step 5: Log In to the Admin Console
Open https://auth.homelab.local in your browser. Use the admin credentials from your .env file.
Security note: After completing setup, disable the
masterrealm's admin console from public access and use only thehomelabrealm for service accounts.
Step 6: Create a Dedicated Realm
A realm is an isolated namespace. Never use master for application clients.
- Click the realm dropdown (top-left) → Create realm
- Realm name:
homelab - Display name:
Homelab SSO - Enabled: On → Create
Alternatively, import a realm JSON via the Keycloak Admin CLI:
# Export realm config later for reproducibility
docker exec -it keycloak /opt/keycloak/bin/kc.sh export \
--dir /opt/keycloak/data/export \
--realm homelab \
--users realm_fileStep 7: Configure Realm Settings
In the homelab realm, navigate to Realm settings:
| Tab | Setting | Value |
|---|---|---|
| General | Display name | Homelab SSO |
| Login | User registration | Off (homelab only) |
| Login | Forgot password | On |
| Login | Remember me | On |
| From address | sso@homelab.local | |
| Tokens | Access token lifespan | 5 minutes |
| Tokens | SSO session idle | 30 minutes |
| Sessions | SSO session max | 10 hours |
Step 8: Create Users
Realm menu → Users → Add user
Username: dylan
Email: dylan@homelab.local
First name: Dylan
Last name: H.
Email verified: On
After saving, go to the Credentials tab → Set password → enter a strong password → toggle Temporary off.
Step 9: Create Groups and Roles
Create groups that map to application roles:
Realm menu → Groups → Create group
| Group | Purpose |
|---|---|
homelab-admins | Full admin access to all services |
homelab-viewers | Read-only access to dashboards |
homelab-devs | Developer access to Gitea, Portainer |
Assign your user to homelab-admins:
Users → dylan → Groups → Join group → homelab-admins
Part 3: OIDC Client — Grafana
Step 10: Create the Grafana Client
Realm menu → Clients → Create client
Client type: OpenID Connect
Client ID: grafana
Name: Grafana Monitoring
Next → Client authentication: On → Next
Set the following URLs:
Root URL: https://grafana.homelab.local
Home URL: https://grafana.homelab.local
Valid redirect URIs: https://grafana.homelab.local/login/generic_oauth
Valid post logout URIs: https://grafana.homelab.local/login/generic_oauth
Web origins: https://grafana.homelab.local
Save → go to Credentials tab → copy the Client secret.
Step 11: Add a Mapper for Groups
In the Grafana client, go to Client scopes → grafana-dedicated → Add mapper → By configuration → Group Membership:
| Field | Value |
|---|---|
| Name | groups |
| Token claim name | groups |
| Full group path | Off |
| Add to ID token | On |
| Add to access token | On |
| Add to userinfo | On |
Step 12: Configure Grafana
Add these environment variables to Grafana's container (or grafana.ini):
# grafana.ini [auth.generic_oauth] section
[auth.generic_oauth]
enabled = true
name = Homelab SSO
allow_sign_up = true
client_id = grafana
client_secret = <paste-secret-from-step-10>
scopes = openid email profile groups
auth_url = https://auth.homelab.local/realms/homelab/protocol/openid-connect/auth
token_url = https://auth.homelab.local/realms/homelab/protocol/openid-connect/token
api_url = https://auth.homelab.local/realms/homelab/protocol/openid-connect/userinfo
role_attribute_path = contains(groups[*], 'homelab-admins') && 'Admin' || contains(groups[*], 'homelab-viewers') && 'Viewer' || 'Editor'Or via environment variables in docker-compose.yml:
environment:
GF_AUTH_GENERIC_OAUTH_ENABLED: "true"
GF_AUTH_GENERIC_OAUTH_NAME: "Homelab SSO"
GF_AUTH_GENERIC_OAUTH_CLIENT_ID: "grafana"
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: "<secret>"
GF_AUTH_GENERIC_OAUTH_SCOPES: "openid email profile groups"
GF_AUTH_GENERIC_OAUTH_AUTH_URL: "https://auth.homelab.local/realms/homelab/protocol/openid-connect/auth"
GF_AUTH_GENERIC_OAUTH_TOKEN_URL: "https://auth.homelab.local/realms/homelab/protocol/openid-connect/token"
GF_AUTH_GENERIC_OAUTH_API_URL: "https://auth.homelab.local/realms/homelab/protocol/openid-connect/userinfo"
GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH: "contains(groups[*], 'homelab-admins') && 'Admin' || 'Viewer'"Restart Grafana — the login page now shows a Sign in with Homelab SSO button.
Part 4: OIDC Client — Portainer
Step 13: Create the Portainer Client
Create a new client in the homelab realm:
Client ID: portainer
Name: Portainer CE
Set these redirect URIs:
Valid redirect URIs: https://portainer.homelab.local/
Web origins: https://portainer.homelab.local
Client authentication: On → save, then copy the client secret.
Step 14: Configure Portainer OAuth
In Portainer → Settings → Authentication → OAuth:
| Field | Value |
|---|---|
| Provider | Custom |
| Client ID | portainer |
| Client secret | <secret> |
| Authorization URL | https://auth.homelab.local/realms/homelab/protocol/openid-connect/auth |
| Access token URL | https://auth.homelab.local/realms/homelab/protocol/openid-connect/token |
| Resource URL | https://auth.homelab.local/realms/homelab/protocol/openid-connect/userinfo |
| Redirect URL | https://portainer.homelab.local/ |
| User ID claim | preferred_username |
| Scopes | openid profile email |
Enable Automatic user provisioning if you want Portainer to create accounts on first login.
Part 5: Traefik Forward Auth (Protect Any App)
Forward auth lets you put SSO in front of apps that have no native OIDC support (e.g., Prometheus, raw web UIs).
Step 15: Create the Forward-Auth Client in Keycloak
Client ID: traefik-forward-auth
Name: Traefik Forward Auth
Valid redirect URIs: https://*.homelab.local/_oauth
Web origins: https://*.homelab.local
Client authentication: On → save, copy secret.
Step 16: Deploy thomseddon/traefik-forward-auth
# Add to your main docker-compose or Traefik stack
forward-auth:
image: thomseddon/traefik-forward-auth:2
container_name: traefik-forward-auth
restart: unless-stopped
environment:
PROVIDERS_OIDC_ISSUER_URL: https://auth.homelab.local/realms/homelab
PROVIDERS_OIDC_CLIENT_ID: traefik-forward-auth
PROVIDERS_OIDC_CLIENT_SECRET: <secret>
SECRET: <random-32-char-string>
AUTH_HOST: auth.homelab.local
COOKIE_DOMAIN: homelab.local
DEFAULT_PROVIDER: oidc
LOG_LEVEL: info
labels:
- "traefik.enable=true"
- "traefik.http.middlewares.sso-auth.forwardauth.address=http://forward-auth:4181"
- "traefik.http.middlewares.sso-auth.forwardauth.authResponseHeaders=X-Forwarded-User"
- "traefik.http.middlewares.sso-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.routers.forward-auth.rule=Host(`auth.homelab.local`) && PathPrefix(`/_oauth`)"
- "traefik.http.routers.forward-auth.middlewares=sso-auth"
networks:
- proxyStep 17: Protect an Application with SSO
Add the sso-auth middleware label to any service you want to gate:
prometheus:
image: prom/prometheus:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.prometheus.rule=Host(`prometheus.homelab.local`)"
- "traefik.http.routers.prometheus.entrypoints=websecure"
- "traefik.http.routers.prometheus.tls=true"
- "traefik.http.routers.prometheus.middlewares=sso-auth" # <-- add this lineAny unauthenticated request now redirects to Keycloak login and back.
Part 6: Multi-Factor Authentication
Step 18: Enforce TOTP for Admin Accounts
In the homelab realm:
- Authentication → Policies → OTP Policy
- Algorithm: SHA1, 6 digits, 30s period
- Authentication → Required actions → Configure OTP → Default action: On
Or enforce MFA only for the homelab-admins group:
Authentication → Flows → Duplicate "browser" flow
Name it browser-mfa. In the copied flow, set the OTP Form authenticator to Required and add a Condition — user attribute check that limits it to users with the requires_mfa attribute, then assign this flow to the homelab-admins group via:
Groups → homelab-admins → Authentication flow overrides → Browser flow: browser-mfa
Testing
Verify Keycloak Health
# Health endpoint (served on internal port 9000 — exec into container or use internal network)
curl -s http://localhost:9000/health/ready | python3 -m json.tool
# Or via the management path exposed through Traefik (requires internal access)
curl -s https://auth.homelab.local/health | python3 -m json.tool
# OIDC discovery document
curl -s https://auth.homelab.local/realms/homelab/.well-known/openid-configuration \
| python3 -m json.tool | grep -E '"issuer|authorization_endpoint|token_endpoint"'Expected output snippet:
{
"issuer": "https://auth.homelab.local/realms/homelab",
"authorization_endpoint": "https://auth.homelab.local/realms/homelab/protocol/openid-connect/auth",
"token_endpoint": "https://auth.homelab.local/realms/homelab/protocol/openid-connect/token"
}Test the Token Flow Manually
# Get an access token via Resource Owner Password Credentials (testing only)
curl -s -X POST \
"https://auth.homelab.local/realms/homelab/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" \
-d "client_id=grafana" \
-d "client_secret=<secret>" \
-d "username=dylan" \
-d "password=<password>" \
-d "scope=openid profile groups" \
| python3 -m json.tool | grep -E '"access_token|expires_in"'Decode the JWT at jwt.io (or with jwt decode):
pip install PyJWT
python3 -c "
import jwt, base64, json
token = '<paste access_token here>'
header = json.loads(base64.b64decode(token.split('.')[0] + '=='))
payload = json.loads(base64.b64decode(token.split('.')[1] + '=='))
print('Header:', json.dumps(header, indent=2))
print('Claims:', json.dumps(payload, indent=2))
"Confirm the groups claim contains homelab-admins.
Grafana SSO Test
- Open
https://grafana.homelab.localin an incognito window - Click Sign in with Homelab SSO — you are redirected to Keycloak
- Log in as
dylan— you are redirected back as Grafana Admin - Check Server Admin → Users — Dylan's account shows role
Admin
Verification Checklist
Infrastructure:
- Keycloak health endpoint returns
{"status": "UP"} - OIDC discovery document reachable at
/.well-known/openid-configuration - TLS certificate valid (Let's Encrypt or internal CA)
- PostgreSQL data persisting across container restarts
Realm Config:
-
homelabrealm created,masterrealm access restricted - Users cannot self-register
- Groups
homelab-adminsandhomelab-viewersexist - Group membership claim present in access tokens
Integrations:
- Grafana shows Sign in with Homelab SSO
- Admin user gets
Adminrole in Grafana via group claim - Portainer OAuth login works
- Traefik forward auth redirects unauthenticated users to Keycloak
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
Invalid parameter: redirect_uri | Redirect URI not registered | Add exact URI in Keycloak client settings |
Client not found | Wrong realm in URL | Check KC_HOSTNAME and realm name in token URL |
| Keycloak OOM-killed | Not enough RAM | Set JAVA_OPTS_APPEND=-Xms256m -Xmx512m in environment |
| PostgreSQL connection refused | DB not ready when Keycloak starts | Add depends_on healthcheck (already in the Compose file above) |
| Redirect loop in forward auth | AUTH_HOST misconfigured | Set AUTH_HOST to your Keycloak host, not forward-auth host |
| Groups claim missing | Mapper not added to client scope | Re-add group membership mapper to the client's dedicated scope |
Backup and Recovery
# Backup PostgreSQL
docker exec keycloak-db pg_dump -U keycloak keycloak \
| gzip > ~/backups/keycloak-$(date +%Y%m%d).sql.gz
# Restore
gunzip -c ~/backups/keycloak-20260326.sql.gz \
| docker exec -i keycloak-db psql -U keycloak keycloak
# Export realm config (portable, version-controllable)
docker exec -it keycloak /opt/keycloak/bin/kc.sh export \
--dir /opt/keycloak/data/export \
--realm homelabCommit the exported realm JSON to a private git repository. On disaster recovery, docker compose up then import the realm JSON via the admin UI or CLI.
Extensions and Next Steps
- LDAP / Active Directory sync — bind Keycloak to your AD for unified user management under User Federation → Add LDAP provider
- Social login — add GitHub or Google as identity providers so users can link external accounts
- Webhook events — configure the Keycloak Event Listener SPI to push login/logout events to your SIEM or n8n workflow for auditing
- Account linking — let existing users link their Keycloak account to a GitHub OAuth identity
- High availability — run two Keycloak nodes with
--cache=ispnand a shared Infinispan/JDBC session store for zero-downtime restarts - Passkey / WebAuthn — enable the built-in WebAuthn authenticator under Authentication → Policies → WebAuthn Policy for passwordless login
Resources
Questions? Join the CosmicBytez community Discord.