Hardening Traefik: Security Headers, Rate Limit, BasicAuth & IP Allowlist
Using Traefik v3 middlewares properly: set security headers, throttle requests, protect services with BasicAuth and an IP allowlist – all verified with curl.
Table of contents
Your Traefik routes requests and fetches certificates – but it does not protect anything yet. Four
middlewares change that: security headers, a rate limit, BasicAuth and an IP allowlist. We build them
one at a time and measure with curl after every step what actually changed.
What are we building?
At the end you have four reusable middlewares and a chain you can attach to any service:
| Middleware | Effect | Measured |
|---|---|---|
sec-headers | HSTS, clickjacking and MIME protection | 5 new response headers |
rate | limits requests per IP | 40 parallel requests → 13× 200, 27× 429 |
auth | BasicAuth in front of the service | without password 401, with it 200 |
only-me | only your IP gets in | foreign IP 403, your own 200 |
Tested with Traefik v3.7.8 on Debian 13. Important if you are migrating: in Traefik v3 the
allowlist is called ipAllowList – the v2 name ipWhiteList no longer exists, and configurations
using it break on upgrade.
Prerequisites
- A running Traefik v3 with a
proxynetwork and working TLS, as built in Traefik as a reverse proxy - A service behind it to practise on. We use
traefik/whoami– a tiny container that echoes every request including its headers, which makes it perfect for verification - A basic grasp of Docker Compose and labels
VPS 1000 G12.5
4 vCores · 8 GB RAM · 128 GB SSD
from €14.50/month
Middlewares cost virtually no resources – this runs on the smallest VPS.
💶 1 month free for new netcup customers:
Single use, valid for VPS 1000 G12.5. Redeem in the cart →
💶 €5 voucher for new netcup customers: always valid · not for domains or VPS Lite
Step by step
1. Measure the starting point
Before hardening anything, record what it looked like before. Create a test service –
~/whoami/compose.yaml, replacing YOUR_DOMAIN:
services:
whoami:
image: traefik/whoami:v1.12
restart: unless-stopped
networks: [proxy]
labels:
- "traefik.enable=true"
- "traefik.http.routers.whoami.rule=Host(`whoami.YOUR_DOMAIN`)"
- "traefik.http.routers.whoami.entrypoints=websecure"
- "traefik.http.routers.whoami.tls.certresolver=le"
- "traefik.http.services.whoami.loadbalancer.server.port=80"
networks:
proxy:
external: truemkdir -p ~/whoami && cd ~/whoami && docker compose up -dNow look at the response headers:
curl -sI https://whoami.YOUR_DOMAIN/HTTP/2 200
content-type: text/plain; charset=utf-8
date: Sat, 25 Jul 2026 07:01:27 GMT
content-length: 351That is the whole truth: Traefik sets no security header at all on its own. TLS gives you an encrypted transport – it does nothing against clickjacking, MIME sniffing or accidental HTTP.
2. Set security headers
With Docker labels, middlewares are defined on any container and activated on a router. Add these labels to the test service:
# activate
- "traefik.http.routers.whoami.middlewares=sec-headers"
# define
- "traefik.http.middlewares.sec-headers.headers.stsSeconds=31536000"
- "traefik.http.middlewares.sec-headers.headers.stsIncludeSubdomains=true"
- "traefik.http.middlewares.sec-headers.headers.stsPreload=true"
- "traefik.http.middlewares.sec-headers.headers.frameDeny=true"
- "traefik.http.middlewares.sec-headers.headers.contentTypeNosniff=true"
- "traefik.http.middlewares.sec-headers.headers.referrerPolicy=strict-origin-when-cross-origin"
- "traefik.http.middlewares.sec-headers.headers.permissionsPolicy=camera=(), microphone=(), geolocation=()"docker compose up -d && sleep 5 && curl -sI https://whoami.YOUR_DOMAIN/HTTP/2 200
content-type: text/plain; charset=utf-8
permissions-policy: camera=(), microphone=(), geolocation=()
referrer-policy: strict-origin-when-cross-origin
strict-transport-security: max-age=31536000; includeSubDomains; preload
x-content-type-options: nosniff
x-frame-options: DENYFive headers more, and each has a purpose:
strict-transport-security(HSTS) tells the browser: talk to this domain over HTTPS only, for a year. That closes the gap between typingexample.comand the redirect to HTTPS.x-frame-options: DENYforbids embedding in foreign<iframe>s – the classic clickjacking protection.x-content-type-options: nosniffstops the browser from „guessing" the content type and, say, executing an uploaded text file as a script.referrer-policyprevents your full internal URL from being sent along when someone clicks an external link.permissions-policydisables camera, microphone and location for this site.
preload is a one-way street
stsPreload=true is the invitation to have your domain added to the browsers’ HSTS preload list
(you apply for the listing separately). Once it is there, browsers enforce HTTPS for the domain and
all its subdomains – even if your server has long stopped serving a certificate. Getting out takes
months. Only set stsIncludeSubdomains and stsPreload if you are sure that every subdomain will
permanently speak HTTPS. To start with, stsSeconds alone is enough.Deliberately not in the list: browserXssFilter. The X-XSS-Protection header is considered
obsolete and modern browsers ignore it. A contentSecurityPolicy is missing too – it is effective,
but differs per application and reliably breaks interfaces when applied blindly.
3. Rate limiting against brute force and bots
This middleware limits how many requests a single IP may make:
- "traefik.http.middlewares.rate.ratelimit.average=5"
- "traefik.http.middlewares.rate.ratelimit.burst=10"
- "traefik.http.middlewares.rate.ratelimit.period=1s"average=5 with period=1s means five requests per second on average. burst=10 allows short
spikes above that – web pages do load several files at once.
Activate it and verify with real load. Important: test in parallel, otherwise you are too slow to hit the limit at all:
seq 40 | xargs -P 8 -I{} curl -s -o /dev/null -w "%{http_code}\n" https://whoami.YOUR_DOMAIN/ \
| sort | uniq -c 13 200
27 429That is exactly how it should look: the first requests go through, after that Traefik answers with
429 Too Many Requests without troubling the service behind it at all.
Sensible values
429 – if they show up in everyday use, the limit is too strict.4. BasicAuth for services without their own login
Some tools have no login at all, or a weak one – a Traefik dashboard, a metrics endpoint, an admin interface. BasicAuth puts a hurdle in front of them before the request ever reaches the application.
First create a password hash. No extra package needed, Docker will do:
docker run --rm httpd:2.4-alpine htpasswd -nbB koch "YOUR_PASSWORD"koch:$2y$05$IcOtS/jq6VR4haHO2Pvtl...-B forces bcrypt (not the old MD5), -nb writes the result to standard output.
Every $ has to be doubled in Compose
$ characters, and Docker Compose treats $ as a variable. So enter the hash
with doubled dollar signs: $2y$05$Ic… becomes $$2y$$05$$Ic…. Forget that and parts of the hash
vanish silently, making the login fail every time – docker compose config shows you what Compose
really produces. - "traefik.http.middlewares.auth.basicauth.users=koch:$$2y$$05$$IcOtS/jq6VR4haHO2Pvtl..."Verify – three cases, three answers:
curl -s -o /dev/null -w "%{http_code}\n" https://whoami.YOUR_DOMAIN/
curl -s -o /dev/null -u koch:wrong -w "%{http_code}\n" https://whoami.YOUR_DOMAIN/
curl -s -o /dev/null -u koch:YOUR_PASSWORD -w "%{http_code}\n" https://whoami.YOUR_DOMAIN/401
401
200On the 401 the browser asks for credentials itself – Traefik sends
www-authenticate: Basic realm="traefik" along with it.
BasicAuth is deliberately simple: no logout, no second factor, no user management. For „nobody but me should even be able to knock here" it is exactly right; for real user accounts across several services, single sign-on is the way to go.
5. IP allowlist: only from home
Even stricter: allow requests only from certain networks. In Traefik v3 the middleware is called
ipAllowList:
- "traefik.http.middlewares.only-me.ipallowlist.sourcerange=YOUR_IP/32"Find your public IP with curl -s https://ifconfig.me. Separate several ranges with commas, e.g.
sourcerange=203.0.113.5/32,198.51.100.0/24.
The test shows both sides. With a foreign IP in the list Traefik answers:
403And with your own:
200Which IP does Traefik even see?
traefik/whoami in the X-Forwarded-For field.
For such setups there is ipallowlist.ipstrategy.depth and excludedips – but first check how many
proxies really sit in front.An IP allowlist is effective but inconvenient: on mobile data or in hotel Wi-Fi you no longer get in. The more elegant variant for „only me" is a WireGuard VPN – then you allow the VPN network instead of changing public IPs.
6. Bundle middlewares into a chain
Several middlewares on one router are listed comma-separated – the order is the execution order.
So you don’t repeat this for every service, there is chain:
- "traefik.http.middlewares.hardened.chain.middlewares=sec-headers,rate,auth"
- "traefik.http.routers.whoami.middlewares=hardened"Verify that everything applies at once:
curl -s -o /dev/null -w "%{http_code}\n" https://whoami.YOUR_DOMAIN/
curl -sI -u koch:YOUR_PASSWORD https://whoami.YOUR_DOMAIN/ | grep -i strict-transport401
strict-transport-security: max-age=31536000; includeSubDomains; preloadWithout credentials 401, with credentials you get the response and the headers. As a rule of
thumb for the order: cheap rejections first. An IP allowlist drops the request without checking
anything; the rate limit costs almost nothing; BasicAuth has to compute a bcrypt hash – and that is
deliberately slow. So only-me, then rate, then auth.
7. The most important use case: the Traefik dashboard
Traefik ships a dashboard showing all routers, services and middlewares – genuinely useful for debugging. It is off by default, and for good reason: it reveals the complete structure of your server. This is exactly where BasicAuth pays off.
Enable the API in the Traefik stack:
command:
- "--api.dashboard=true"
# … your existing parameters …And give Traefik labels for itself. The trick is the internal service api@internal:
labels:
- "traefik.enable=true"
- "traefik.http.routers.dashboard.rule=Host(`traefik.YOUR_DOMAIN`)"
- "traefik.http.routers.dashboard.entrypoints=websecure"
- "traefik.http.routers.dashboard.tls.certresolver=le"
- "traefik.http.routers.dashboard.service=api@internal"
- "traefik.http.routers.dashboard.middlewares=dashboard-auth"
- "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$2y$$05$$IcOtS/jq6VR4haHO2Pvtl..."After docker compose up -d, check both entry points – the dashboard and the API underneath it:
curl -s -o /dev/null -w "%{http_code}\n" https://traefik.YOUR_DOMAIN/dashboard/
curl -s -o /dev/null -u admin:YOUR_PASSWORD -w "%{http_code}\n" https://traefik.YOUR_DOMAIN/dashboard/
curl -s -o /dev/null -w "%{http_code}\n" https://traefik.YOUR_DOMAIN/api/overview
curl -s -u admin:YOUR_PASSWORD https://traefik.YOUR_DOMAIN/api/overview401
200
401
{"http":{"routers":{"total":3,"warnings":0,"errors":0},"services":{"total":5, …The third line is the important one: the API is protected just like the interface. Had you attached
the middleware only to a /dashboard path, /api/… would still be open – and with it your entire
configuration readable. Because the router here matches the whole host, the login covers both.
Tip
only-me (IP allowlist) and
dashboard-auth as a chain. Then an attacker has to be on the right network before they may even guess a
password. For a dashboard you rarely need, that is the right level of hardening.8. Define middlewares centrally (the scalable way)
So far we defined everything through labels. With five services you copy the same lines five times – and changing the HSTS duration means editing five files. Better: define them once, centrally, in the file provider.
Add two startup parameters and a mount to the Traefik stack:
command:
# … your existing parameters …
- "--providers.file.directory=/dynamic"
- "--providers.file.watch=true"
volumes:
# … your existing mounts …
- ./dynamic:/dynamic:roThen the middlewares as YAML in ~/traefik/dynamic/middlewares.yml:
http:
middlewares:
sec-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
frameDeny: true
contentTypeNosniff: true
referrerPolicy: strict-origin-when-cross-origin
rate:
rateLimit:
average: 5
burst: 10
period: 1sRestart Traefik – afterwards you attach them to any service with the suffix @file:
- "traefik.http.routers.whoami.middlewares=sec-headers@file,rate@file"Verified: headers and rate limit behave exactly as they did via labels (12× 200, 18× 429 for 30
parallel requests in the test). The gain: --providers.file.watch=true picks up changes to the file
without a restart, and the HSTS duration now lives in exactly one place.
Where does a middleware come from?
sec-headers@docker, those loaded from files are sec-headers@file. Within the same provider you may
omit the suffix – across provider boundaries you may not. That is exactly what causes most
„middleware not found" errors.9. Which middleware for which service?
Not every service needs everything. The right chain depends on who should use it and what it already brings along:
| Type of service | Examples | sensible chain |
|---|---|---|
| Public, for everyone | website, blog, status page | sec-headers |
| Public with a login | Nextcloud, Immich, Vaultwarden | sec-headers, rate |
| Only for you, with a login | Uptime Kuma, Grafana | sec-headers, rate (+ allowlist if possible) |
| Without its own login | Traefik dashboard, metrics endpoints | only-me, rate, auth |
| Dangerous if abused | Dockge, Portainer, Adminer | only-me, auth – or not public at all |
Two rules behind that: no rate limit in front of services that need many parallel requests – Immich
during a phone backup or a media server while streaming will otherwise run into 429. And: never put
BasicAuth in front of an app that has its own login if it has a mobile app – most apps cannot cope
with two stacked authentications.
For everything in the last row, the honest answer is usually not „one more middleware" but: don’t put it on the open internet, reach it through WireGuard.
When things go wrong
The logs say „middleware … does not exist" and the service answers with 404. Almost always the
provider suffix is missing: a middleware defined in the file provider is called name@file from the
perspective of Docker labels. Without the suffix Traefik looks for it among the Docker labels – and
finds nothing. The same applies the other way round for name@docker.
BasicAuth rejects the correct password. The $ characters in the bcrypt hash were interpreted as
variables by Docker Compose. In Compose they have to be doubled ($$2y$$05$$…). docker compose config shows the value that really arrives.
The rate limit doesn’t seem to work. Sequential curl calls are too slow: every process opens a
new connection, so you stay below the limit. Test in parallel, e.g. with
seq 40 | xargs -P 8 -I{} curl … – then the 429s appear.
After setting the IP allowlist you get 403 yourself. Traefik does not see your IP but that of the
proxy in front of it (Cloudflare, load balancer). Check with traefik/whoami what X-Forwarded-For
contains, and use ipallowlist.ipstrategy.depth for multi-stage setups.
A v2 configuration with ipWhiteList stops working after the upgrade. In Traefik v3 the middleware
is called ipAllowList; the old name was removed, so the rule no longer applies. Rename every
occurrence when migrating.
The browser still refuses plain HTTP even though you fixed the configuration. That is HSTS, not a
bug: the browser remembers max-age and enforces HTTPS even when your server no longer offers it. A
private window or a different browser helps for testing – and that is why the warning about preload
exists above.
Your problem is not listed? Search all error messages →
Maintenance & backups
Re-check after every Traefik update. Middlewares are configuration, and configuration ages: between
v2 and v3 ipWhiteList became ipAllowList, other fields got new names. After an update a quick pass
with curl -sI is worth it – do the expected headers still appear? Does the allowlist still answer
403? Two minutes that save you from silent ineffectiveness.
Back up the dynamic directory. If you follow the file provider, it holds your entire hardening. It
belongs in the backup together with compose.yaml and acme.json – small, but painful to reconstruct.
How to automate that is covered in Restic backups.
Allowlists age. Dynamic IP addresses of home connections change, office networks move. Take a look
at your sourcerange entries once a quarter and remove anything you can no longer place – an allowlist
containing strangers’ addresses is worse than none.
Honest about the reach of these measures. Security headers protect your users’ browsers, not your server. A rate limit slows brute force down, it does not prevent it. BasicAuth is a door with a lock, not a user concept. What these four middlewares do not replace: a hardened SSH access, a firewall, automatic updates and fail2ban or CrowdSec for the layer below. The middlewares are the layer that sits in front of your applications – not the only one.
Send feedback: feedback@serverkueche.de
You might also like


