Traefik
Traefik is bundled in the Full Docker layout (docker-compose.full.yml) and the Swarm layout (docker-compose.swarm.yml). It runs as the traefik service, terminates HTTP on :80, routes by path prefix and exposes a dashboard at /traefik. There's nothing to install separately.
TLS comes via two additive compose overlays wired by install.sh --ssl:
| Mode | Overlay | When |
|---|---|---|
letsencrypt | docker-compose.tls-letsencrypt.yml | Public-internet hosts. ACME TLS-ALPN challenge — needs :80 and :443 open from the public internet. Cert + renewal handled by Traefik automatically. |
provided | docker-compose.tls-provided.yml | Corporate CA, internal PKI, air-gapped install. Operator supplies the .crt and .key; Traefik serves them via the file provider. |
none (default) | (no overlay) | HTTP-only on :80. Fine for local dev or behind another reverse proxy that terminates TLS upstream. |
For the conceptual map of compose files + scripts, read Docker first. For the cross-page COMPOSE_FILE rule, see Docker → COMPOSE_FILE discipline.
What's pre-wired
Routing priorities
The full layout uses path-prefix routing on a single Traefik HTTP entrypoint (:80). Higher priority wins; the catchall liberty-next router runs last:
| Router | Rule | Priority | Service |
|---|---|---|---|
traefik-dashboard | PathPrefix(`/traefik`) + a strict allow-list of /api/* GETs | 1000 | Traefik's internal api@internal service. |
pgadmin | PathPrefix(`/pgadmin`) | 100 | pgAdmin container on :80. |
portainer | PathPrefix(`/portainer`) (stripped to /) | 100 | Portainer container on :9000. |
liberty-next | PathPrefix(`/`) (catchall) | 1 | liberty-next container on :8000. |
Liberty-next stays the catchall — every request that doesn't match a more specific prefix lands there. Adding a new app behind the same Traefik just means a new router at priority > 1.
Sticky cookies for Socket.IO
Liberty-next keeps Socket.IO state in-process. When you scale it to more than one replica, the sticky cookie pins each client to the replica it first hit:
traefik.http.services.liberty-next.loadbalancer.sticky.cookie.name: "liberty_sticky"
The cookie is on by default — harmless at replicas: 1, essential at > 1. Note: stickiness keeps a given client pinned, but it does NOT share Socket.IO state across replicas. Live dashboards / chat streams emitted by one replica won't reach clients pinned to another. For real multi-replica, a Redis adapter is needed — not yet built in.
The dynamic config
Traefik reads two kinds of configuration:
| Source | What |
|---|---|
| Container labels | Per-service routes and middlewares, declared inline in the compose file. |
traefik/dynamic/*.yml | Shared middlewares whose values contain $ (basic-auth bcrypt hashes — Compose substitution would eat them) and TLS cert references for the provided mode (tls.yml, generated by install.sh). |
Two flags wire it up:
- --providers.file.directory=/etc/traefik/dynamic
- --providers.file.watch=true
file.watch=true means edits reload in seconds, no container restart needed.
The bundled dynamic file ships three middlewares:
| Middleware | What it does | Use |
|---|---|---|
traefik-auth | Basic-auth gate (default admin / admin — bcrypt hash). | Attached to the dashboard router via traefik-auth@file. |
security-headers | frameDeny, contentTypeNosniff, browserXssFilter, referrerPolicy: no-referrer-when-downgrade. STS lines commented out (uncomment when serving HTTPS). | Attach to any router with <router>.middlewares: "security-headers@file". |
redirect-to-https | 301 redirect HTTP → HTTPS. | Attach to every HTTP-entrypoint router once the websecure entrypoint is enabled. |
When --ssl provided is wired, install.sh also generates traefik/dynamic/tls.yml (gitignored) referencing the cert + key filenames inside /etc/certs. Operators can edit it manually for fancier setups (multi-domain, separate intermediates, SNI rules) — Traefik picks the edits up via file.watch.
Change the dashboard password
The default admin / admin works for the first 30 seconds. Then change it.
docker run --rm httpd:alpine htpasswd -nbB admin "<your-password>"
# admin:$2y$05$abc...
Paste the one line of output into release/traefik/dynamic/dynamic.yml under http.middlewares.traefik-auth.basicAuth.users:
http:
middlewares:
traefik-auth:
basicAuth:
users:
- "admin:$2y$05$<your-new-hash>"
# Multiple users? Add one per line:
# - "ops:$2y$05$..."
file.watch=true picks it up within seconds — no container restart. Refresh the dashboard, sign in with the new credentials.
htpasswd -nbB produces a bcrypt hash ($2y$). Traefik also accepts apr1 ($apr1$) but bcrypt is the recommended modern choice — works in any env without escaping issues.
Mode 1 — Let's Encrypt
For public-internet hosts. Traefik fetches the certificate via the ACME TLS-ALPN challenge, persists it in the traefik-acme named volume (mounted at /acme inside Traefik) and renews automatically.
Install
./install.sh full --ssl letsencrypt \
--domain liberty.example.com \
--email ops@example.com
Requirements:
| Requirement | Why |
|---|---|
| The hostname resolves to this host (DNS A and/or AAAA record). | Without DNS, ACME can't issue a cert. |
:80 AND :443 reachable from the public internet. | The TLS-ALPN challenge runs on :443 but Traefik also needs :80 open for HTTP-to-HTTPS redirects. |
Read-write access to the traefik-acme named volume. | Where the issued cert is stored. |
A real, monitored email address for ACME_EMAIL. | Let's Encrypt sends expiry warnings there. |
What install.sh does:
- Appends
docker-compose.tls-letsencrypt.ymltoCOMPOSE_FILEin.env. - Sets
LIBERTY_DOMAIN=<hostname>andACME_EMAIL=<address>in.env. - Removes any leftover
traefik/dynamic/tls.ymlfrom a previousprovidedinstall. - Runs
docker compose up -d— Traefik comes up on:80+:443and requests the cert on the first HTTPS request.
The overlay (small):
services:
traefik:
environment:
TRAEFIK_ENTRYPOINTS_WEBSECURE_ADDRESS: ":443"
TRAEFIK_CERTIFICATESRESOLVERS_LE_ACME_EMAIL: "${ACME_EMAIL:?ACME_EMAIL required for letsencrypt mode}"
TRAEFIK_CERTIFICATESRESOLVERS_LE_ACME_STORAGE: "/acme/acme.json"
TRAEFIK_CERTIFICATESRESOLVERS_LE_ACME_TLSCHALLENGE: "true"
ports:
- "${TRAEFIK_HTTPS_PORT:-443}:443"
labels:
traefik.http.routers.traefik-dashboard.entrypoints: "web,websecure"
traefik.http.routers.traefik-dashboard.tls: "true"
traefik.http.routers.traefik-dashboard.tls.certresolver: "le"
liberty-next:
labels:
traefik.http.routers.liberty-next.entrypoints: "web,websecure"
traefik.http.routers.liberty-next.tls: "true"
traefik.http.routers.liberty-next.tls.certresolver: "le"
pgadmin:
labels:
traefik.http.routers.pgadmin.entrypoints: "web,websecure"
traefik.http.routers.pgadmin.tls: "true"
traefik.http.routers.pgadmin.tls.certresolver: "le"
portainer:
labels:
traefik.http.routers.portainer.entrypoints: "web,websecure"
traefik.http.routers.portainer.tls: "true"
traefik.http.routers.portainer.tls.certresolver: "le"
Three things to notice:
| Detail | Why |
|---|---|
Settings go through Traefik's TRAEFIK_<UPPER_DOT_TO_UNDER>=value env-var interface. | Compose overlay-merging is REPLACE-by-key for array values (command:) and ADD-by-key for env. Using env-vars avoids losing the base compose's CLI flags when the overlay merges. |
ACME storage at /acme/acme.json. | The base compose mounts traefik-acme at /acme (NOT /etc/traefik/acme/ — that path is the read-only /etc/traefik bind mount, so a second subdir mount under it isn't possible). |
Every router opts into both entrypoints (web,websecure). | Lets users hit either http:// or https:// until you wire a redirect — see below. |
Force HTTP → HTTPS
Add the redirect-to-https@file middleware (already defined in dynamic.yml) to each router, only on the web entrypoint:
services:
liberty-next:
labels:
traefik.http.routers.liberty-next.middlewares: "redirect-to-https@file"
Or edit the base compose's labels directly.
Mode 2 — Operator-provided certs
For corporate CA, internal PKI or air-gapped installs that can't reach Let's Encrypt.
Install
./install.sh full --ssl provided \
--domain liberty.internal.example.com \
--cert-dir /etc/pki/tls \
--cert-file liberty.crt \
--key-file liberty.key
Requirements:
| Requirement | Why |
|---|---|
A directory on the host containing the .crt (or .pem) AND the private key file. | Both bind-mounted into the container at /etc/certs:ro. |
The cert file matches --cert-file and the key file matches --key-file inside that directory. | install.sh validates both exist before continuing. |
The cert is valid for the --domain you pass. | Browsers reject hostname mismatches. |
What install.sh does:
- Appends
docker-compose.tls-provided.ymltoCOMPOSE_FILE. - Sets
CERT_HOST_PATH=<abs-path-to-cert-dir>in.env— Traefik bind-mounts that directory at/etc/certs:ro. - Sets
LIBERTY_DOMAIN=<hostname>in.env(orlocalhostif--domainwas omitted). - Generates
traefik/dynamic/tls.yml(gitignored — re-running--ssl providedoverwrites it) referencing the cert + key filenames inside/etc/certs. - Runs
docker compose up -d— Traefik comes up on:80+:443serving the supplied cert.
The overlay:
services:
traefik:
environment:
TRAEFIK_ENTRYPOINTS_WEBSECURE_ADDRESS: ":443"
ports:
- "${TRAEFIK_HTTPS_PORT:-443}:443"
volumes:
- "${CERT_HOST_PATH:?CERT_HOST_PATH required for provided mode}:/etc/certs:ro"
labels:
traefik.http.routers.traefik-dashboard.entrypoints: "web,websecure"
traefik.http.routers.traefik-dashboard.tls: "true"
liberty-next:
labels:
traefik.http.routers.liberty-next.entrypoints: "web,websecure"
traefik.http.routers.liberty-next.tls: "true"
pgadmin: { labels: { traefik.http.routers.pgadmin.entrypoints: "web,websecure", traefik.http.routers.pgadmin.tls: "true" } }
portainer: { labels: { traefik.http.routers.portainer.entrypoints: "web,websecure", traefik.http.routers.portainer.tls: "true" } }
And the generated tls.yml:
tls:
stores:
default:
defaultCertificate:
certFile: /etc/certs/liberty.crt
keyFile: /etc/certs/liberty.key
certificates:
- certFile: /etc/certs/liberty.crt
keyFile: /etc/certs/liberty.key
Notice the routers do NOT carry a certresolver — the cert comes via the file-provider default store. Traefik picks it up automatically.
Edit tls.yml for more cert / SNI rules
tls.yml is gitignored and reload-watched. For multi-domain setups, replace the file by hand:
tls:
certificates:
- certFile: /etc/certs/liberty.crt
keyFile: /etc/certs/liberty.key
- certFile: /etc/certs/api.crt
keyFile: /etc/certs/api.key
stores:
default:
defaultCertificate:
certFile: /etc/certs/liberty.crt
keyFile: /etc/certs/liberty.key
Traefik watches the file and re-reads it within seconds.
install.sh --ssl provided overwrites tls.ymlA re-run of ./install.sh full --ssl provided with new --cert-file / --key-file values REPLACES tls.yml from the template. Custom multi-cert edits are lost. Keep a copy of your edited version, or version it outside the release/ checkout.
Switching modes later
Re-run ./install.sh full --ssl <new-mode> ... with the same secrets in place. install.sh:
- Swaps the overlay path in
COMPOSE_FILE. - Rewrites
tls.yml(forprovided) or removes it (forletsencrypt). - Runs
docker compose up -d, which picks up the new overlay.
Operator-managed state (the traefik-acme volume, the cert host directory) is preserved across mode flips.
Compose vs Swarm
The full layout's Traefik service uses --providers.docker to discover backends from container labels. Swarm uses --providers.swarm (reads from deploy.labels instead of top-level labels). The two compose files differ accordingly:
Compose (docker-compose.full.yml) | Swarm (docker-compose.swarm.yml) | |
|---|---|---|
| Provider flag | --providers.docker=true | --providers.swarm=true |
| Network filter | --providers.docker.network=liberty-network | --providers.swarm.network=liberty-network |
| Endpoint | implicit (docker.sock) | --providers.swarm.endpoint=unix:///var/run/docker.sock |
| Labels location | Top-level labels: block per service | deploy.labels: block per service |
| Middleware suffix | @docker (e.g. traefik-strip@docker) | @swarm (e.g. traefik-strip@swarm) |
| File-provider middlewares | @file (same on both) | @file (same on both) |
Both layouts:
- Mount the Docker socket read-only.
- Mount
./traefik:/etc/traefik:rofor the dynamic config. - Use the
traefik-acmenamed volume mounted at/acme(for cert storage in LE mode).
Swarm caveat — Traefik must run on a manager
The Swarm provider reads the Docker API on the socket — only manager nodes expose it. The bundled compose pins Traefik via:
deploy:
placement:
constraints:
- node.role == manager
Swarm --ssl caveat
install.sh --ssl is Compose-only — Swarm operators apply the TLS overlay manually by passing -c repeatedly:
docker stack deploy \
-c docker-compose.swarm.yml \
-c docker-compose.tls-letsencrypt.yml \
liberty
(With LIBERTY_DOMAIN, ACME_EMAIL exported into the shell first — deploy-swarm.sh does that.)
Common operations
Reload the dynamic config
file.watch=true is on — just edit traefik/dynamic/dynamic.yml (or tls.yml) and save. Traefik picks it up in ~5 s and emits a log line. No restart.
Reload after a static-config change
Anything in the command: block of the Traefik service is static config — needs a container restart:
docker compose up -d traefik # COMPOSE_FILE picks the right files
Or, in Swarm:
docker service update --force liberty_traefik
View live routers and services
The dashboard at /traefik shows every router with its rule, entrypoint, middleware chain and the backing service. Useful when debugging "why isn't this route matching" — Traefik shows you what it sees.
Tail Traefik logs
docker compose logs -f traefik
# or in Swarm:
docker service logs -f liberty_traefik
ACME issuance / renewal lands here. So do routing errors (no available server) when a backend service is down.
Common pitfalls
| Mistake | Symptom | Fix |
|---|---|---|
| Domain doesn't resolve when Traefik runs the ACME challenge. | Logs show acme: error 400 — DNS problem or unauthorized. | Verify dig <domain> returns the server's IP from a public resolver. |
:80 or :443 blocked at the firewall / cloud LB. | TLS-ALPN challenge fails. | Open both at the host firewall + the cloud LB. ACME needs :80 open too. |
Re-ran --ssl provided — manual tls.yml edits are gone. | Custom multi-cert config disappeared. | Keep a copy outside release/. Or version-control your own tls.yml. |
Passing -f docker-compose.full.yml after install. | TLS overlay + apps overlay silently dropped on next up -d. | NEVER -f after install — COMPOSE_FILE is the source of truth. See Docker → COMPOSE_FILE discipline. |
Edit to dynamic.yml doesn't reload. | Old basic-auth password still works. | Confirm --providers.file.watch=true is in the static command block; check Traefik logs for parse errors. |
Swarm Traefik fails to start with cannot connect to Docker socket. | Traefik reports Cannot connect to the Docker daemon. | Traefik is on a worker — Swarm rescheduled it. Pin to manager. |
| Catchall liberty-next intercepts /pgadmin / /portainer. | pgAdmin / Portainer URLs return Liberty's SPA. | Check router priorities — liberty-next must be priority 1; pgAdmin / Portainer at 100. |
ACME cert stored in /etc/traefik/acme/ instead of /acme/. | Cert path errors. | The base compose mounts /etc/traefik:ro (read-only); ACME storage goes at /acme instead. The bundled overlay already uses /acme. |
What's next
- Docker → Full — the full layout walkthrough.
- Docker → Swarm — the Swarm layout walkthrough.
- Portainer + pgAdmin — what the other bundled visual tools are for.
- Production — the rest of the hardening checklist.