traefik is my reverse proxy for every web interface, inside the lan and
outside. it runs as one replica on the swarm. i chose it over
nginx proxy manager, which it replaced, so every
route is a line in git, reviewed in a pull request and checked before it
deploys.
every request reaches an app through the VIP and traefik:
services:traefik:image:traefik:v3.7.13@sha256:24841fe2de7304c149343d877d2923b4c8800a38ba015dea9174c23b20e344a0environment:# (1)!-TZ=America/Los_Angeles-CF_DNS_API_TOKEN_FILE=/run/secrets/traefik_cf_token_v1ports:# (2)!-80:80-443:443-1444:1444configs:-source:traefik_statictarget:/etc/traefik/traefik.ymlsecrets:-traefik_cf_token_v1volumes:-acme:/acme-type:volumesource:dynamictarget:/dynamicread_only:truenetworks:-default-traefik-apihealthcheck:test:["CMD","traefik","healthcheck","--ping"]interval:30stimeout:5sretries:3start_period:20sdeploy:mode:replicatedreplicas:1update_config:# (3)!order:start-firstconfig-sync:image:registry.k8s.io/git-sync/git-sync:v4.7.1environment:-TZ=America/Los_Angeles-GITSYNC_REPO=ssh://git@ssh.github.com:443/<you>/<repo>.git-GITSYNC_REF=deploy/swarm/traefik-GITSYNC_PERIOD=60s-GITSYNC_ROOT=/git-GITSYNC_LINK=repo-GITSYNC_SSH_KEY_FILE=/dev/shm/key-GITSYNC_SSH_KNOWN_HOSTS_FILE=/known_hosts-GITSYNC_EXECHOOK_COMMAND=/sync-routes.sh-GITSYNC_FILTER=blob:none-GITSYNC_SPARSE_CHECKOUT_FILE=/sparse-checkout-GITSYNC_HTTP_BIND=:8080entrypoint:-/bin/sh--c-|umask 077printf '%s\n' "$$(cat /run/secrets/gitsync_ssh_key_v1)" > /dev/shm/key[ -s /dev/shm/key ] || { echo "config-sync: could not write the SSH key to /dev/shm" >&2; exit 1; }mkdir -p /tmp/.ssh && cp /ssh_config /tmp/.ssh/config || { echo "config-sync: could not install the ssh config" >&2; exit 1; }exec /git-syncvolumes:-type:bindsource:/usr/share/zoneinfotarget:/usr/share/zoneinforead_only:true-dynamic:/dynamicconfigs:-source:traefik_known_hoststarget:/known_hosts-source:traefik_ssh_configtarget:/ssh_config-source:traefik_sparse_checkouttarget:/sparse-checkout-source:traefik_sync_routestarget:/sync-routes.shmode:0555secrets:-gitsync_ssh_key_v1deploy:# (4)!mode:globalconfigs:traefik_static:file:./traefik.ymlname:traefik_static_v2traefik_known_hosts:file:./known_hostsname:traefik_known_hosts_v2traefik_ssh_config:file:./ssh_configname:traefik_ssh_config_v2traefik_sparse_checkout:file:./sparse-checkoutname:traefik_sparse_checkout_v1traefik_sync_routes:file:./sync-routes.shname:traefik_sync_routes_v1networks:traefik-api:external:truesecrets:traefik_cf_token_v1:external:truegitsync_ssh_key_v1:external:truevolumes:acme:# (5)!driver:localdriver_opts:type:nonedevice:"/mnt/docker-cephFS/traefik_acme"o:binddynamic:# (6)!driver:localdriver_opts:type:tmpfsdevice:tmpfso:"uid=65533,gid=65533,mode=0755,size=1m"
traefik's ACME library, lego, reads any of its settings from a file named by <NAME>_FILE, so the
cloudflare token comes from a docker secret and never enters the service spec.
published through the ingress mesh, so every node's ports reach the one replica, and 1444 is the
external listener the router's 443 forward points at. the mesh hides the client's address, as it
does for npm.
the new task starts before the old one stops, because every name behind the proxy goes down while
it restarts.
one per node, each writing into that node's own dynamic volume. traefik reloads when a file there
changes, and on cephfs a write from another node never tells it, so the writer sits beside traefik
wherever swarm puts it.
the ACME account and the certificate, on cephfs so they follow traefik to any node. create it with
mode 700 before the first deploy.
in memory, one per node, and created owned by git-sync's own user, so its hook can write without a
chown. it only empties when both services on a node stop, and the next sync refills it.
traefik.yml: the static configuration, 71 lines, 5 notes
drops headers like X_Auth_User, which PHP or nginx backends read as X-Auth-User, so a client can't
forge the identity headers a sign-in proxy adds.
encoded null and backslash have no use in a path and are rejected. the rest stay allowed, as npm
allowed them, because every route matches on the host name and never on the path.
one wildcard certificate for every name. per-host certificates would put each internal name in the
public certificate transparency logs, and adding a service would wait on issuing one.
the external listener, a separate entrypoint from 443, so a route is reachable from outside only when
the registry gives it an external mode.
my domain is also my active directory domain inside the lan. left to the container's resolver, lego
would look for the challenge's TXT record on the windows DNS servers and time out, since the record
only exists in cloudflare.
create a cloudflare API token with Zone / Zone / Read and
Zone / DNS / Edit, limited to your zones, and store it as a docker
secret. a name outside your domain needs its zone in the token too:
dockersecretcreatetraefik_cf_token_v1-
paste the token, then press Ctrl-D
traefik reads the token from the secret's file, and the service spec
never holds it
in cloudflare, set each zone's encryption mode to full (strict), so
cloudflare checks the certificate traefik serves. traefik also takes
cloudflare's post-quantum key agreement to the origin, which needs
TLS 1.3
create the certificate folder on the cephfs mount:
sudomkdir-m700/mnt/docker-cephFS/traefik_acme
without the folder the bind fails and traefik doesn't start. that's
better than starting with no certificate state
create gitsync_ssh_key_v1, the read-only deploy key config-sync uses to
read the repo, as in gatus's steps.
gatus and homepage use the same key, so skip this if either is deployed
the stack declares the key as an external secret. without it the deploy
fails with secret not found
create the traefik-api overlay on a manager, with a fixed subnet:
traefik's API router only answers this subnet, so the subnet has to match
the registry. pick one clear of the /24s docker hands out in order from
10.0.0.0/8
no stack owns the overlay, so removing traefik, homepage or gatus leaves
it for the other two
check nothing on the swarm already listens on 80, 443 or 1444
the certificate and the let's encrypt account are in
/mnt/docker-cephFS/traefik_acme, a named bind on cephfs, see
stack conventions.
they follow traefik to whichever node it starts on
the routes are in a small in-memory volume on each node, filled by that
node's config-sync. it holds nothing that isn't in git
there is one replica. traefik's free edition can't share its let's encrypt
state between instances
traefik publishes three ports on the ingress mesh, so each one answers on
every node and on the keepalived VIP:
port
for
80
redirects every name to https on the same name, keeping the path. the router forwards its 80 here
443
the lan
1444
outside. the router forwards its 443 here
the mesh replaces each client's address with its own, so traefik's logs show
10.0.0.x. nothing here decides anything on the client's address
homepage and gatus read traefik's read-only API on the unpublished 8080,
over the traefik-api overlay. the dashboard's name is behind oauth, which a
widget or a health check can't pass
8080 also listens on the ingress network that every published service
shares, so the router for the API only answers traefik-api's subnet
traefik doesn't join the discovery overlay. traefik faces the internet,
and discovery carries the docker socket proxy
config-sync runs on every node (mode: global). traefik reloads when a file
in its folder changes, and a write on one node's cephfs mount never reaches
a watcher on another, so each node writes its own copy of the routes
services.yaml has one entry per name. my script, tools/traefik_render.py,
renders it into dynamic/routes.yml, which traefik reads. it also writes a
gatus check for every route, and refuses an unknown
field or a name used twice. CI fails if the files disagree with the registry,
then starts the pinned traefik on them and fails if any route reports an
error.
a service that already serves 443 with a valid certificate stays direct on
the lan. home assistant and the unifi gateway keep their own names and
certificates. traefik is for everything that lacks one or both.
where traefik sends requests. a list gets a health check on / and a sticky cookie; proxmox uses all three nodes
upstream_tls: insecure
skips the check of a backend's certificate, for one that is self-signed or expired
tls_name
checks a backend's certificate against this name, for a backend reached by address
host
the name, when it isn't the key under the domain. a name outside the domain's wildcard gets a certificate of its own from the same resolver
internal
open, or oauth for oauth2-proxy in front. leave it out and the name isn't served on the lan
external
oauth, own-mfa or public. leave it out and the name isn't served outside
mfa
with own-mfa, a line saying how the app enforces MFA, so a reviewer sees the claim
native
the app's own login and MFA, and its own address when it differs from upstream. required
external_oauth_paths, external_oauth_headers
oauth in front of part of an own-mfa or public name only
cross_site_paths
refuses, with 403, every request another site started, except to these exact paths. browsers say who started a request in the Sec-Fetch-Site header, which a page can't set, so a link or redirect elsewhere can't reach the rest of the app
redirect
the name only redirects, keeping the path
root_redirect
the front page redirects, and every other path is served
external_redirect
the name is served on the lan, and outside every path redirects, keeping the path
services.yaml: the registry, one entry per name, 438 lines
domain:mydomain.comvip:192.168.1.45oauth:host:auth.mydomain.comupstream:http://192.168.1.45:4180other_endpoints:-name:Frigate APIaddress:"192.168.1.86:5000"auth:"none:thewholeAPI,configandcameracontrolincluded"mfa:"none"-name:MQTT (mosquitto)address:"192.168.1.45:1883"auth:"none:allow_anonymoustrue,soanyonecanpublish,Zigbee2MQTT'scommandtopicsincluded"mfa:"none"-name:Docker API proxy (truenas1)address:"192.168.1.86:2375"auth:"none:read-onlyDockerAPI"mfa:"none"-name:Ollamaaddress:"192.168.1.86:30068"auth:"none"mfa:"none"-name:FlareSolverraddress:"192.168.1.86:8191"auth:"none"mfa:"none"-name:Portainer agent (swarm nodes)address:"192.168.1.41,.42,.43:9001"auth:"none:noAGENT_SECRET,soanyPortainerthatreachesitcanadoptthenode(owneraccepted,2026-09-22)"mfa:"none"-name:Portainer agent (truenas1, pi-zwave01)address:"192.168.1.86:9001,192.168.1.96:9001"auth:"unknown:notdefinedinthisrepo,sowhetherAGENT_SECRETissetisunchecked"mfa:"none"-name:Dozzle agentsaddress:"192.168.1.41,.42,.43,.31,.86,.96:7007"auth:"mutualTLSwiththeshareddozzleagentcertificate"mfa:"none"-name:oauth2-proxyaddress:"192.168.1.45:4180"auth:"itistheEntrasign-in;nothingisbehinditbuta202"mfa:"Entra"services:traefik:upstream:api@internalinternal:oauthnative:auth:"none:itsread-onlyAPIhasnologin"mfa:"none"direct:"noportofitsown;theAPIisalsoon:8080,fortraefik-apionly"apprise:upstream:http://192.168.1.45:8050internal:oauthnative:auth:"none"mfa:"none"bentopdf:upstream:http://192.168.1.45:8091internal:opennative:auth:"none"mfa:"none"blog:upstream:http://192.168.1.45:8180internal:openexternal:publicnative:auth:"WordPresspasswordlogin(wp-login.php)"mfa:"unknown:checkfora2FAplugin"apex:host:mydomain.comupstream:http://192.168.1.45:8180internal:openexternal:publicexternal_redirect:https://www.mydomain.comnative:auth:"WordPresspasswordlogin,atacustomaddress(wp-login.phpanswers404);thenetworkadminishere"mfa:"unknown:checkfora2FAplugin"mydomain1:host:mydomain1.comupstream:http://192.168.1.45:8180external:publicnative:auth:"WordPresspasswordlogin,atacustomaddress(wp-login.phpanswers404)"mfa:"unknown:checkfora2FAplugin"dozzle:upstream:http://192.168.1.41:8888internal:oauthnative:auth:"none"mfa:"none"gatus:upstream:http://192.168.1.45:8085internal:opennative:auth:"none"mfa:"none"homepage:upstream:http://192.168.1.45:3000internal:openexternal:oauthnative:auth:"none"mfa:"none"ha:upstream:https://192.168.1.63:443tls_name:homeassistant.mydomain.comexternal:own-mfamfa:Home Assistant's authenticator-app (TOTP) module, which every user must have turned onnative:auth:"password"mfa:"authenticator-appmodule:notyetconfirmedforeveryuser"direct:"192.168.1.63:443(homeassistant.mydomain.com)"infinitude:upstream:http://192.168.1.45:4000internal:opennative:auth:"none"mfa:"none"npm:upstream:http://192.168.1.45:181internal:opennative:auth:"password(NPM'sadmin)"mfa:"none"omni-tools:upstream:http://192.168.1.45:8090internal:opennative:auth:"none"mfa:"none"portainer:upstream:http://192.168.1.45:9000internal:openexternal:own-mfamfa:Portainer's own OAuth sign-in to Entra, where Conditional Access requires MFA; its password login and API keys need the proxy's Entra sign-in outsideexternal_oauth_paths:-/api/authexternal_oauth_headers:-X-API-Keynative:auth:"nativeoauth(Entra);theinitialadmin'spassword(alwayson);APIkeys"mfa:"Entra;thepasswordandAPIkeyshavenone"direct:"192.168.1.45:9000,:9443"unifiapibrowser:upstream:http://192.168.1.45:8010internal:oauthnative:auth:"none:itsloginisturnedoff(NOAPIBROWSERAUTH=1),anditholdsaUniFilogin"mfa:"none"www:upstream:http://192.168.1.45:8180internal:openexternal:publicnative:auth:"WordPresspasswordlogin(wp-login.php)"mfa:"unknown:checkfora2FAplugin"adguard1:upstream:http://192.168.1.5:80internal:opennative:auth:"password"mfa:"none"adguard2:upstream:http://192.168.1.6:3000internal:opennative:auth:"password"mfa:"none"bazarr:upstream:http://192.168.1.86:6767internal:oauthnative:auth:"password(form)"mfa:"none"grafana:upstream:http://192.168.1.86:30037internal:opennative:auth:"nativeoauth(Entra),grafana'sAzureADsetting;passwordform"mfa:"Entra;thepasswordformhasnone"jellyfin:upstream:http://192.168.1.86:8096internal:opennative:auth:"password"mfa:"none"open-webui:upstream:http://192.168.1.86:31028internal:opennative:auth:"password(sign-upoff)"mfa:"none"profilarr:upstream:http://192.168.1.86:6868internal:oauthnative:auth:"password(nativeoauthsupported,notsetup)"mfa:"none"prometheus:upstream:http://192.168.1.86:30104internal:opennative:auth:"none"mfa:"none"prowlarr:upstream:http://192.168.1.86:9696internal:oauthnative:auth:"password(forms,requiredforall)"mfa:"none"qbittorrent:upstream:http://192.168.1.86:8080internal:oauthnative:auth:"password(subnetwhitelistoff)"mfa:"none"radarr:upstream:http://192.168.1.86:7878internal:oauthnative:auth:"password(forms,requiredforall)"mfa:"none"sabnzbd:upstream:http://192.168.1.86:8081internal:oauthnative:auth:"password"mfa:"none"searxng:upstream:http://192.168.1.86:30053internal:opennative:auth:"none"mfa:"none"seerr:upstream:http://192.168.1.86:5055internal:oauthexternal:oauthnative:auth:"password(Jellyfinorlocalaccounts)"mfa:"none"sonarr:upstream:http://192.168.1.86:8989internal:oauthnative:auth:"password(forms,requiredforall)"mfa:"none"versity:upstream:https://truenas1.mydomain.com:30355internal:opennative:auth:"S3accesskeyandsecret"mfa:"none"glances-docker01:upstream:http://192.168.1.41:61208internal:opennative:auth:"none"mfa:"none"glances-docker02:upstream:http://192.168.1.42:61208internal:opennative:auth:"none"mfa:"none"glances-docker03:upstream:http://192.168.1.43:61208internal:opennative:auth:"none"mfa:"none"glances-pi-zwave01:upstream:http://192.168.1.96:61208internal:opennative:auth:"none"mfa:"none"glances-pve1:upstream:http://192.168.1.81:61208internal:opennative:auth:"none"mfa:"none"glances-pve2:upstream:http://192.168.1.82:61208internal:opennative:auth:"none"mfa:"none"glances-pve3:upstream:http://192.168.1.83:61208internal:opennative:auth:"none"mfa:"none"glances-syn02:upstream:http://192.168.1.31:61208internal:opennative:auth:"none"mfa:"none"glances-truenas1:upstream:http://192.168.1.86:61208internal:opennative:auth:"none"mfa:"none"nvr:upstream:https://192.168.1.86:8971tls_name:frigate.mydomain.cominternal:opennative:auth:"password"mfa:"none"proxmox:upstream:-https://pve1.mydomain.com:8006-https://pve2.mydomain.com:8006-https://pve3.mydomain.com:8006internal:opennative:auth:"nativeoauth(Entra),realm'AzureAAD';passwordrealmspam,pveandAD"mfa:"Entra;root@pam:TOTPeverywhere,andaWebAuthnpasskeyonproxmox.mydomain.comonly(thenodes':8006failsProxmox'sorigincheck,whichneedsport443;seeninpve1'slog2026-09-27);recoverykeysheld;otherpam,pveandADusers:unknown"direct:"pve1-3.mydomain.com:8006"pbs:upstream:https://pbs1.mydomain.com:8007internal:opennative:auth:"nativeoauth(Entra),realm'EntraID';passwordrealmspamandpbs"mfa:"Entra;thepasswordrealms:unknown(per-userTOTP)"pbs-restore:upstream:https://192.168.1.45:8008upstream_tls:insecureinternal:opencross_site_paths:[/,/login,/login/oidc/callback]native:auth:"Proxmox'ssign-in,peruser:nativeoauth(Entra)throughtherealm'AzureAAD';thepam,pveandADpasswordrealmsalsoappear"mfa:"Entra,itsonlyMFA:theportalcan'tdoProxmox'sTOTP.Thepasswordrealms:none"syn01:upstream:https://syn01.mydomain.com:5101upstream_tls:insecureexternal:own-mfamfa:DSM's 2-step verification (one-time codes), on for every account (owner, 2026-09-27)native:auth:"password"mfa:"one-timecodes(owner)"direct:"192.168.1.30:5101(syn01.mydomain.com)"syn02:upstream:https://syn02.mydomain.com:5101external:own-mfamfa:DSM's 2-step verification (one-time codes), on for every account (owner, 2026-09-27)native:auth:"password"mfa:"one-timecodes(owner)"direct:"192.168.1.31:5101(syn02.mydomain.com)"unifi:upstream:https://192.168.1.1:443tls_name:unifi.mydomain.comexternal:own-mfamfa:UniFi OS login, with MFA on the account (owner, 2026-09-27)native:auth:"UniFiOSlogin"mfa:"MFAontheaccount(owner)"direct:"192.168.1.1:443(efg.mydomain.com)"watch-your-lan:upstream:http://192.168.1.31:8840internal:opennative:auth:"none"mfa:"none"zigbee2mqtt:upstream:http://192.168.1.96:8080internal:oauthnative:auth:"unknown:auth_tokennotchecked(thePi'sSSHhostkeyisnottrustedhere)"mfa:"none"zwave-js-ui:upstream:http://192.168.1.96:80internal:oauthnative:auth:"password(owner)"mfa:"none"
dynamic/routes.yml: generated from the registry, never edited by hand, 600 lines
#!/bin/shset-eu
src="./stacks/swarm/traefik/dynamic"dst="/dynamic"[-d"$src"]||{echo"config-sync: $src not found under $(pwd)">&2;exit1;}[-d"$dst"]||{echo"config-sync: $dst is not mounted">&2;exit1;}if!ls"$src"/*.yml>/dev/null2>&1;thenecho"config-sync: no *.yml under $src, refusing to reconcile">&2exit1fidelivered=0forfin"$src"/*.yml;doname="${f##*/}"tmp="$dst/.${name}.tmp"cp"$f""$tmp"chmod0644"$tmp"mv"$tmp""$dst/$name"delivered=$((delivered+1))doneremoved=0forgin"$dst"/*.yml;do[-f"$g"]||continuename="${g##*/}"[-f"$src/$name"]&&continuerm-f"$g"removed=$((removed+1))echo"config-sync: removed $name, no longer in git"doneecho"config-sync: delivered $delivered, removed $removed, at ${GITSYNC_HASH:-unknown}"
a name marked oauth asks oauth2-proxy about every request
first. a browser that isn't signed in goes to entra, and comes back to the page
it asked for. the sign-in lands on auth.mydomain.com and sets a cookie on the
whole domain, so one sign-in covers every name. the oauth2-proxy page has the
entra side.
on the lan, a name is open or oauth, chosen per name. oauth protects
the name only: the app's own port still answers on the lan
an app whose users sign in with entra through its own OIDC setting, such as
proxmox or portainer, needs no oauth in front of it
auth has every name's sign-in and MFA, on the lan and
outside
outside, every name needs MFA or has to be a public site. external has no
open, and the script refuses a name that sets one.
oauth puts oauth2-proxy in front, so users sign in with entra first
own-mfa relies on the app's own login, and needs the mfa line
public is for a site meant for anyone, such as the blog
some apps with their own MFA have a second way in that skips it. portainer's
first admin can always log in with a password, and an API key skips the login
altogether. external_oauth_paths and external_oauth_headers put oauth in
front of just those requests. each path is also matched with %2F in place of
its inner slashes, because the app reads /api%2Fauth as /api/auth and a
plain rule doesn't.
the dashboard is at https://traefik.mydomain.com, behind oauth. it lists
every route with its status
gatus checks every route through the VIP, with the
name as the Host header and no DNS. a mis-wired route goes red on its own,
and a DNS outage doesn't turn them all red
gatus also checks the certificate's expiry and goes red with 21 days left.
traefik renews at 30