- Why this stack?
- Getting started
- Features
- Supply chain trust
- Production checklist
- Backups
- Restoring a database backup
- Testing
- Security Notes
- About the maintainer
This repository deploys Keycloak behind Traefik with automatic Let's Encrypt TLS, backed by PostgreSQL, with a scheduled backup container and a companion restore script. One docker compose up away from a production-shaped identity-and-access-management service at https://your-domain.
π Full narrative installation guide on the blog: heyvaldemar.com/install-keycloak-using-docker-compose/.
| Need | This stack | Manual install | Keycloak Helm (K8s) | Other compose examples |
|---|---|---|---|---|
| Ready to deploy in <10 min | β | β hours of setup | β if K8s is already running | Often |
| TLS via Let's Encrypt, auto-renewed | β Traefik ACME built-in | Manual certbot | Via cert-manager | Varies |
| Runs on Docker Compose (no Kubernetes required) | β | N/A | β K8s required | β |
| PostgreSQL bundled with healthcheck + start-order dependency | β | Separate install | β | Varies |
| Scheduled DB backups + pruning | β | Manual cron | External (Velero etc.) | Rare |
| One-command restore script | β | Manual pg_restore |
Manual | Rare |
Upstream images pinned by sha256 digest |
β | N/A | Depends on chart | Rare |
| Dependabot-tracked weekly updates | β | N/A | Depends | Rare |
| CI-verified deployment + backup/restore on every push | β | N/A | Varies | Rare |
| Credentials via env (never committed) | β | N/A | K8s Secrets | Often committed plaintext |
Four moving parts (Traefik + Keycloak + Postgres + backups). No hidden complexity, no Kubernetes prerequisites, no manual certificate management.
# 1. Clone
git clone https://github.com/heyvaldemar/keycloak-traefik-letsencrypt-docker-compose
cd keycloak-traefik-letsencrypt-docker-compose
# 2. Create the two Docker networks the stack expects
docker network create traefik-network
docker network create keycloak-network
# 3. Copy the environment template and fill in required values
cp .env.example .env
$EDITOR .env
# ^ Required: KEYCLOAK_DB_PASSWORD, KEYCLOAK_ADMIN_PASSWORD,
# TRAEFIK_BASIC_AUTH, TRAEFIK_ACME_EMAIL, TRAEFIK_HOSTNAME,
# KEYCLOAK_HOSTNAME. See .env.example for generation commands.
# 4. Deploy
docker compose -f keycloak-traefik-letsencrypt-docker-compose.yml -p keycloak up -dWithin a minute or two, both https://${KEYCLOAK_HOSTNAME} (Keycloak UI) and https://${TRAEFIK_HOSTNAME} (Traefik dashboard, basic-auth protected) are live with fresh Let's Encrypt certificates.
Apply .env or compose-file changes:
docker compose -f keycloak-traefik-letsencrypt-docker-compose.yml -p keycloak up -d --force-recreate- Keycloak latest stable (26.2.5) with PostgreSQL 16 backing store.
- Traefik v3 reverse proxy with automatic HTTPβHTTPS redirect at entry-point level and Let's Encrypt TLS-ALPN challenge for cert issuance.
- Basic-auth protected Traefik dashboard on a separate hostname.
- Prometheus metrics exposed by Traefik (
--metrics.prometheus) β wire your own scraper. - Healthchecks on every service (Postgres
pg_isready, Keycloak/health/ready, Traefik/ping) with service-dependency ordering (depends_on: condition: service_healthy). - Scheduled PostgreSQL backups with configurable interval, retention, and destination path.
- Automated restore script (
keycloak-restore-database.sh) with interactive backup selection. - Traefik exposed-by-default disabled β only services with
traefik.enable=truelabels are routed. - Credentials required at deploy time β compose fails fast if
.envis incomplete, preventing accidental boots with empty or default credentials.
- Self-hosted SSO for homelabs β wire up Nextcloud, Grafana, Portainer, GitLab (or anything OIDC-capable) behind Keycloak federation.
- Small-team identity provider β consultancies, startups, internal tools that outgrew shared passwords.
- Developer sandbox β spin up a realistic Keycloak for integration testing without provisioning a managed IdP.
- Step toward production Kubernetes β run the Docker Compose stack first, validate the shape, then migrate to a Helm chart once the config is known-good.
This repository is a deployment template, not a custom Docker image. It orchestrates three upstream images:
traefikβ reverse proxy, Docker Hub official imagequay.io/keycloak/keycloakβ Keycloak upstreampostgresβ PostgreSQL, Docker Hub official image
All three are pinned to tag@sha256:<digest> in .env.example. Compose pulls by digest, not by tag. Two users deploying this repo on different days get byte-identical image manifests regardless of upstream repushes.
Dependabot's docker ecosystem watches each digest and opens a weekly PR when any of them changes. CI's Deployment Verification workflow runs on every push, pull request, and every Monday at 06:00 UTC β it stands up the full compose stack with ephemeral credentials, validates HTTPS routing + Traefik dashboard smoke, and tears down. Drift in upstream images surfaces within a week instead of on the next user deploy.
GitHub Actions are also pinned by commit SHA with # vX.Y.Z version comments. Dependabot's github-actions ecosystem keeps those fresh.
See SECURITY.md for the disclosure policy.
Before exposing this to real users, check every box:
- Rotate the bootstrap admin.
KEYCLOAK_ADMIN_USERNAME/PASSWORDcreate a single admin on first start. After login, create your real admin users (preferably via Keycloak Federation or a second-factor-protected account), then disable or delete the bootstrap admin from the Keycloak UI. - Strong secrets everywhere.
KEYCLOAK_DB_PASSWORDandKEYCLOAK_ADMIN_PASSWORDmust be at least 24 random characters. Generate withopenssl rand -base64 24 | tr -d '/+=' | head -c 32. Traefik dashboard BCrypt hash must be regenerated per deployment. - Host-mount the backups volume. By default the
backupsservice writes to a named docker volume. For disaster recovery, bind-mount it to a host path that's included in your off-host backup solution:- /srv/keycloak-postgres/backups:/srv/keycloak-postgres/backups. - Verify Let's Encrypt cert issuance. Watch Traefik logs during first start:
docker compose -p keycloak logs traefik -f. A successful TLS-ALPN challenge logsAdding certificate for domain(s) ${KEYCLOAK_HOSTNAME}within ~30 seconds. - Lock down the Traefik dashboard. The dashboard is basic-auth protected by default, but basic auth is basic. Consider restricting the dashboard's router to specific source IPs via Traefik's
IPAllowListmiddleware, or skip exposing it publicly and rely ondocker compose logs. - Plan your upgrade path. Keycloak does not guarantee DB-schema compatibility across major versions. Before bumping
KEYCLOAK_IMAGE_TAGfrom 26.x to 27.x (when released), read Keycloak's migration guide, test the bump on a staging database restored from a recent backup. - Know the restore procedure. Run
./keycloak-restore-database.shagainst a test environment before you need it in production. Document theBACKUP_PATHand restore steps alongside your other DR runbooks.
The backups container runs on the same network as Postgres and performs a dump β prune β sleep loop:
- Dump β
pg_dumpof the Keycloak database piped throughgzip, timestamp-named.set -o pipefailcatchespg_dumpfailures even thoughgzipexits 0. Failed dumps are renamed with a.failedsuffix for diagnosis; the loop continues to the next cycle. - Prune β deletes files matching
${KEYCLOAK_POSTGRES_BACKUP_NAME}-*.gzolder thanKEYCLOAK_POSTGRES_BACKUP_PRUNE_DAYSdays. SetPRUNE_DAYS=0to disable pruning entirely. - Sleep β waits
KEYCLOAK_BACKUP_INTERVALbefore the next dump.
All four knobs (KEYCLOAK_BACKUP_INIT_SLEEP, KEYCLOAK_BACKUP_INTERVAL, KEYCLOAK_POSTGRES_BACKUP_PRUNE_DAYS, KEYCLOAK_POSTGRES_BACKUPS_PATH) are configured via .env. See .env.example for defaults (30-minute warm-up, 24-hour interval, 7-day retention).
Verify backups are running:
docker compose -p keycloak logs backups | tail -20Expected output β one timestamped line per backup cycle:
[2026-04-23T03:00:01+00:00] Starting backup to /srv/keycloak-postgres/backups/keycloak-postgres-backup-2026-04-23_03-00-01.gz
[2026-04-23T03:00:03+00:00] Backup OK: /srv/keycloak-postgres/backups/keycloak-postgres-backup-2026-04-23_03-00-01.gz (47382 bytes)
A Backup FAILED line (with the partial file renamed to .failed) is your signal that something is broken β typically the postgres container is unhealthy, the backup volume filled up, or the DB credentials were rotated without updating the backups container environment.
Off-host replication. By default backups live in the keycloak-database-backups Docker volume β if the host dies, backups die with it. For disaster recovery, bind-mount the backup path to a host directory that your off-host backup solution (restic, rclone, Borg, S3 sync, etc.) already covers:
# docker-compose.override.yml
services:
backups:
volumes:
- /srv/keycloak-postgres/backups:/srv/keycloak-postgres/backupskeycloak-restore-database.sh handles the restore flow end-to-end with safety guards at every step where data loss is possible:
- Sources
.envβ DB name/user/backups path read from your live configuration (not hardcoded). Works after you customise the defaults. - Lists available backups from the backups volume.
- Prompts for selection β you copy-paste the filename. The script rejects typos / path-traversal by validating the selection against the listed filenames.
- Integrity-checks the selected archive via
gunzip -t. A corrupt archive is caught here, before anything is touched. - Requires
DESTROYconfirmation β typing anything else (including empty) aborts without changes. - Creates a pre-restore snapshot of the CURRENT database state at
/tmp/pre-restore-<timestamp>.gzinside the backups container. This is your rollback if the restore produces a broken DB. - Stops Keycloak, drops + recreates the database, pipes the selected backup into
psql. - Starts Keycloak, waits up to 2 minutes for the healthcheck to report
healthy, then runs a sanity query confirming thepublicschema has tables.
If step 8 fails (Keycloak unhealthy, or the restored DB has 0 public-schema tables), the script exits non-zero and prints the exact command sequence to recover from the pre-restore snapshot.
Make the script executable, then run from the repository root (where .env lives):
chmod +x keycloak-restore-database.sh
./keycloak-restore-database.shThe script uses the PGPASSWORD inherited from the backups container, so no credentials need to be passed on the command line.
RTO / RPO expectations for the default configuration:
| Metric | Default value | How to tighten |
|---|---|---|
| RPO (max data loss) | 24 hours (one KEYCLOAK_BACKUP_INTERVAL) |
Reduce KEYCLOAK_BACKUP_INTERVAL (e.g. 1h) |
| RTO (typical restore time) | 1-3 minutes on a small DB; scales with DB size | Keep Keycloak state lean (realms + clients only, ship audit logs elsewhere) |
| Backup retention | 7 days (one PRUNE_DAYS) |
Increase KEYCLOAK_POSTGRES_BACKUP_PRUNE_DAYS |
| Pre-restore snapshot | Automatic before every restore, kept at /tmp/pre-restore-*.gz inside the backups container |
β |
The Deployment Verification workflow runs end-to-end backup + restore tests on every push, every pull request, and every Monday at 06:00 UTC. The backup-restore-e2e job boots the full compose stack with ephemeral credentials and short backup intervals (INIT_SLEEP=10s, INTERVAL=30s, PRUNE_DAYS=7) and exercises seven scenarios:
.envrequired βdocker compose configfails cleanly without.env, guarding the${VAR:?...}compose syntax.- Backup created β a
.gzappears in the backups volume with size > 0. - Backup integrity β
gunzip -ton the backup exits zero. - Backup contents valid β decompressed SQL contains
PostgreSQL database dumpheader andCREATE TABLE/CREATE SCHEMA. - Backup failure detected β stopping postgres forces a failed cycle; a
*.failedfile andBackup FAILEDlog line are produced. - Restore roundtrip β inserting a marker row, restoring an earlier backup, and asserting the marker is gone proves the backup is genuinely restorable (not a no-op).
- Prune removes old β a fake file with 14-day-old mtime is deleted on the next prune cycle; recent backups are preserved.
Run the same tests locally:
# Bring the stack up first, with short backup intervals in .env β see tests/README.md
docker compose -f keycloak-traefik-letsencrypt-docker-compose.yml -p keycloak up -d
./tests/e2e-backup-restore.shA green backup-restore-e2e run is the authoritative proof that the backup + restore flow works end-to-end on every push. If you deploy this template and hit an unexpected issue, compare the green CI run's logs to your own β most "doesn't work" cases trace to DNS propagation, firewall rules, hostname mismatches, or a customised .env that silently breaks a variable the tests cover.
- Credentials are read from
.envat deploy time..envis gitignored. The compose file uses${VAR:?...}syntax sodocker compose upfails immediately with a helpful error if any required variable is missing. - Pre-rotation advisory. Commits before PR #12 (merged 2026-04-23) committed real credential values. Those values remain in git history but are no longer referenced by any live file. Anyone who deployed with the pre-rotation configuration should rotate their live credentials and regenerate the Traefik dashboard BCrypt hash.
- Traefik dashboard is behind basic auth. Consider adding IP allow-listing for additional isolation.
- Upstream image digests are pinned; Dependabot auto-opens weekly PRs when digests change.
- CI runs on every push and every Monday to catch upstream drift.
See SECURITY.md for the vulnerability disclosure process.
Maintained by Vladimir Mikhalev β Docker Captain Β· IBM Champion Β· AWS Community Builder