Skip to content
Serverküche
Search

Loading search … (only available on the published site).

Containers Difficulty: Advanced

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.

· 12 min read ·Duration: approx. 60 minutes
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:

MiddlewareEffectMeasured
sec-headersHSTS, clickjacking and MIME protection5 new response headers
ratelimits requests per IP40 parallel requests → 13× 200, 27× 429
authBasicAuth in front of the servicewithout password 401, with it 200
only-meonly your IP gets inforeign 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 proxy network 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
🍳 Recommendation Ad

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.

Go to netcup →

💶 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:

YAML
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: true
Terminal
mkdir -p ~/whoami && cd ~/whoami && docker compose up -d

Now look at the response headers:

Terminal
curl -sI https://whoami.YOUR_DOMAIN/
Ausgabe
HTTP/2 200
content-type: text/plain; charset=utf-8
date: Sat, 25 Jul 2026 07:01:27 GMT
content-length: 351

That 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:

YAML
      # 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=()"
Terminal
docker compose up -d && sleep 5 && curl -sI https://whoami.YOUR_DOMAIN/
Ausgabe
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: DENY

Five 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 typing example.com and the redirect to HTTPS.
  • x-frame-options: DENY forbids embedding in foreign <iframe>s – the classic clickjacking protection.
  • x-content-type-options: nosniff stops the browser from „guessing" the content type and, say, executing an uploaded text file as a script.
  • referrer-policy prevents your full internal URL from being sent along when someone clicks an external link.
  • permissions-policy disables 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:

YAML
      - "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:

Terminal
seq 40 | xargs -P 8 -I{} curl -s -o /dev/null -w "%{http_code}\n" https://whoami.YOUR_DOMAIN/ \
  | sort | uniq -c
Ausgabe
     13 200
     27 429

That 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

For a normal web interface, 5–20 requests per second with a burst of 20–50 is realistic. Set it too tight and you block real users loading a single page. After enabling it, watch the Traefik logs for 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:

Terminal
docker run --rm httpd:2.4-alpine htpasswd -nbB koch "YOUR_PASSWORD"
Ausgabe
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

bcrypt hashes contain $ 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.
YAML
      - "traefik.http.middlewares.auth.basicauth.users=koch:$$2y$$05$$IcOtS/jq6VR4haHO2Pvtl..."

Verify – three cases, three answers:

Terminal
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/
Ausgabe
401
401
200

On 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:

YAML
      - "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:

Ausgabe
403

And with your own:

Ausgabe
200

Which IP does Traefik even see?

Traefik checks the IP of the direct connection. If something sits in front of it – Cloudflare, a load balancer, another proxy – you see that component’s IP, and an allowlist with your own address locks you out. What actually arrives is revealed by 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:

YAML
      - "traefik.http.middlewares.hardened.chain.middlewares=sec-headers,rate,auth"
      - "traefik.http.routers.whoami.middlewares=hardened"

Verify that everything applies at once:

Terminal
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-transport
Ausgabe
401
strict-transport-security: max-age=31536000; includeSubDomains; preload

Without 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:

YAML
    command:
      - "--api.dashboard=true"
      # … your existing parameters …

And give Traefik labels for itself. The trick is the internal service api@internal:

YAML
    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:

Terminal
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/overview
Ausgabe
401
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

Even better than BasicAuth alone is the combination for the dashboard: 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:

YAML
    command:
      # … your existing parameters …
      - "--providers.file.directory=/dynamic"
      - "--providers.file.watch=true"
    volumes:
      # … your existing mounts …
      - ./dynamic:/dynamic:ro

Then the middlewares as YAML in ~/traefik/dynamic/middlewares.yml:

YAML
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: 1s

Restart Traefik – afterwards you attach them to any service with the suffix @file:

YAML
      - "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?

Traefik appends the origin to every name: middlewares defined through labels are internally 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 serviceExamplessensible chain
Public, for everyonewebsite, blog, status pagesec-headers
Public with a loginNextcloud, Immich, Vaultwardensec-headers, rate
Only for you, with a loginUptime Kuma, Grafanasec-headers, rate (+ allowlist if possible)
Without its own loginTraefik dashboard, metrics endpointsonly-me, rate, auth
Dangerous if abusedDockge, Portainer, Admineronly-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.

You might also like