Guides

fail2ban with Traefik

Traefik doesn't write an access log unless you ask it to, and when it runs in Docker a normal ban doesn't touch its traffic. This guide sets up the log, the stock traefik-auth jail, a 404 jail and bans that actually block.

Updated 6 min readChecked against fail2ban 1.1.1

To use fail2ban with Traefik, enable Traefik's access log in Common Log Format (--accesslog=true, --accesslog.filepath=/var/log/traefik/access.log), mount that directory on the host, and enable fail2ban's stock traefik-auth jail, which bans clients that fail HTTP basic auth (status 401). If Traefik runs in Docker, add banaction = iptables-allports and chain = DOCKER-USER, because Docker-forwarded traffic never passes the INPUT chain.

/etc/fail2ban/jail.d/traefik.local
[traefik-auth]
enabled   = true
backend   = auto
logpath   = /var/log/traefik/access.log
banaction = iptables-allports
chain     = DOCKER-USER
maxretry  = 5
bantime   = 1h
bash
sudo fail2ban-client reload
sudo fail2ban-client status traefik-auth

Step 1: enable Traefik's access log#

fail2ban reads files (or the journal); Traefik's access log is off by default and goes to stdout when enabled without a path. A Compose setup for Traefik v3 that writes it to a host directory:

compose.yaml
services:
  traefik:
    image: traefik:v3.5
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      - --accesslog=true
      - --accesslog.filepath=/var/log/traefik/access.log
      - --accesslog.format=common
      - --accesslog.filters.statuscodes=400-499
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - /var/log/traefik:/var/log/traefik

  whoami:
    image: traefik/whoami
    labels:
      - traefik.enable=true
      - traefik.http.routers.whoami.rule=Host(`whoami.example.com`)
      - traefik.http.routers.whoami.entrypoints=websecure
      - traefik.http.routers.whoami.tls=true
      - traefik.http.routers.whoami.middlewares=auth
      - traefik.http.middlewares.auth.basicauth.users=admin:$$apr1$$H6uskkkW$$IgXLP6ewTrSuBkTrqE8wj/
  • format=common produces Traefik's Common Log Format, which the stock filter expects. JSON logs need a different filter.
  • filters.statuscodes=400-499 keeps only client errors, which is all fail2ban needs and keeps the file small. Leave it out if you also want the log for other purposes.
  • Don't set accesslog.bufferingsize: buffered lines reach the file late, and fail2ban reacts late.
  • $$ escapes $ in Compose labels; generate your own hash with htpasswd -nb admin 'secret'.

Rotate the file on the host with logrotate; Traefik reopens its log file when it receives USR1. Pin the Traefik image tag you actually run; the example uses a v3 tag.

Step 2: the traefik-auth filter and its modes#

A failed basic-auth request in Traefik's CLF looks like this (client, user, time, request, status, size, referrer, user agent, request count, router, backend URL, duration):

log
203.0.113.42 - admin [25/Oct/2026:18:36:10 +0000] "GET /dashboard/ HTTP/2.0" 401 17 "-" "Mozilla/5.0" 42 "dashboard@docker" "-" 0ms

The traefik-auth filter matches 401 responses. Its mode decides which ones:

Mode Counts 401s… Use when
normal (default) with a username (- admin [) You want to catch password guessing only
ddos without a username (- - [) You want to catch clients hammering protected URLs without credentials
aggressive both Most setups behind basic auth or forward auth
/etc/fail2ban/jail.d/traefik.local
[traefik-auth]
enabled = true
backend = auto
mode    = aggressive
logpath = /var/log/traefik/access.log

Browsers make one credential-less request (401, no user) before showing the login prompt, so in ddos and aggressive mode keep maxretry at 5 or more. You can restrict the counted methods with filter = traefik-auth[mode=aggressive, req-method="GET|POST|HEAD"].

Step 3: ban 404 scanners behind Traefik#

Traefik's CLF starts like nginx's combined format (client, -, user, [time], "request", status), so the generic access-log filter from the nginx guide works unchanged:

/etc/fail2ban/filter.d/nginx-4xx.conf
[Definition]
failregex = ^<HOST> \S+ \S+ \[\] "[A-Z]+ [^"]*" (?:<codes>)\s
ignoreregex = ^<HOST> \S+ \S+ \[\] "(?:GET|HEAD) /(?:favicon\.ico|robots\.txt|apple-touch-icon\S*\.png)\s
datepattern = ^[^\[]*\[({DATE})
              {^LN-BEG}

[Init]
codes = 401|403|404
/etc/fail2ban/jail.d/traefik.local
[traefik-404]
enabled   = true
backend   = auto
filter    = nginx-4xx[codes="404"]
logpath   = /var/log/traefik/access.log
banaction = iptables-allports
chain     = DOCKER-USER
maxretry  = 20
findtime  = 10m
bantime   = 1h

We tested it against a Traefik line such as 203.0.113.42 - - [25/Oct/2026:18:36:10 +0000] "GET /.git/config HTTP/2.0" 404 19 "-" "Mozilla/5.0" 42 "whoami@docker" "http://172.18.0.4:80" 1ms — it matches. For other formats, see writing a custom filter.

Step 4: make the ban apply to Traefik in Docker#

Traefik's ports 80 and 443 are published by Docker, which DNATs them to the container. Packets then traverse FORWARD, not INPUT, so the default fail2ban ban (iptables INPUT or the nftables input hook) never sees them. chain = DOCKER-USER with iptables-allports puts the ban where Docker looks first. Full background, the nftables variant and the startup-order fix: fail2ban with Docker containers.

If Traefik runs with network_mode: host or as a binary on the host, traffic does pass INPUT — then drop the banaction/chain lines and use the default action.

Real client IP: forwardedHeaders and Cloudflare#

Traefik writes the client address into the access log's first field. When the request carries an X-Forwarded-For header that Traefik kept, the log shows that header's value instead of the TCP peer address. Traefik keeps the header only from addresses listed in the entrypoint's forwardedHeaders.trustedIPs and strips it from everyone else, so a client can't fake its logged IP:

yaml
      - --entrypoints.websecure.forwardedheaders.trustedips=10.0.0.5/32,192.168.1.0/24

List only proxies you control — or Cloudflare's published ranges if Cloudflare sits in front. Never use forwardedHeaders.insecure=true on an internet-facing entrypoint. Behind Cloudflare a local firewall ban stops Cloudflare's edge server, not the attacker, so ban at Cloudflare instead: see the Cloudflare guide.

Test it#

bash
# filter against the real log
sudo fail2ban-regex /var/log/traefik/access.log 'traefik-auth[mode=aggressive]'

# trigger: wrong password five times from another machine
curl -u admin:wrong https://whoami.example.com/

sudo fail2ban-client status traefik-auth
sudo iptables -L DOCKER-USER -n --line-numbers

If fail2ban-regex shows 0 matched although the log has 401 lines, check that the format is common (not json) and that the jail's logpath is the host path, not the path inside the container.

fail2ban vs CrowdSec for Traefik#

CrowdSec is the other common choice with Traefik. It parses the same access log, but blocks through a Traefik bouncer plugin (a middleware) or a firewall bouncer, and shares attack signals with its community blocklist. fail2ban is a single package with no account and no plugin, bans in the host firewall, and is easy to reason about. If you want ready-made scenarios and community blocklists, CrowdSec fits; if you want a small, local tool you fully control, fail2ban does the job. A full comparison is in fail2ban vs CrowdSec.

The traefik-auth filter reference#

The built-in filters this guide uses, exactly as fail2ban 1.1.1 ships them — with the log lines they match and a link to test them. All other built-in filters are in the filter library.

traefik-auth Traefik reverse proxy

The traefik-auth filter bans clients failing Basic/Digest auth in Traefik's access log (401 responses). File: /etc/fail2ban/filter.d/traefik-auth.conf; jail [traefik-auth]; log on Debian/Ubuntu: /var/log/traefik/access.log.

Log lines it bans (from fail2ban's test suite):

  1. 10.0.0.2 - username [18/Nov/2018:21:34:30 +0000] "GET /dashboard/ HTTP/2.0" 401 17 "-" "Mozilla/5.0 (X11; Linux x86_64; rv:57.0) Gecko/20100101 Firefox/57.0" 72 "Auth for frontend-Host-traefik-0" "/dashboard/" 0ms
  2. 10.0.0.2 - username [18/Nov/2018:21:34:34 +0000] "GET /dashboard/ HTTP/2.0" 401 17 "-" "Mozilla/5.0 (X11; Linux x86_64; rv:57.0) Gecko/20100101 Firefox/57.0" 72 "Auth for frontend-Host-traefik-0" "/dashboard/" 0ms
  3. 10.0.0.2 - username [18/Nov/2018:21:34:34 +0000] "TRACE /dashboard/ HTTP/2.0" 401 17 "-" "Mozilla/5.0 (X11; Linux x86_64; rv:57.0) Gecko/20100101 Firefox/57.0" 72 "Auth for frontend-Host-traefik-0" "/dashboard/" 0ms
Show the failregex fail2ban compiles (1 line)
failregex (1)
^<HOST> \- (?!- )<F-USER>\S+</F-USER> \[\] \"(?:\S+) [^\"]+\" 401\b

Test traefik-auth in the browser or on the server: sudo fail2ban-regex /var/log/traefik/access.log traefik-auth · source

FAQ#

Does fail2ban work with Traefik?#

Yes. Enable Traefik's access log in Common Log Format, write it to a file on the host, and enable the stock traefik-auth jail with logpath pointing to it. When Traefik runs in Docker, also set banaction = iptables-allports and chain = DOCKER-USER so bans affect traffic forwarded to the container.

Where does Traefik write its access log?#

Nowhere until you enable it. --accesslog=true alone writes to stdout; add --accesslog.filepath=/var/log/traefik/access.log and mount /var/log/traefik from the host so fail2ban can read the file. The stock jail expects exactly /var/log/traefik/access.log.

What is the difference between traefik-auth modes?#

normal counts 401 responses that include a username (wrong password), ddos counts 401 responses without a username (no credentials sent), and aggressive counts both. Set it with mode = aggressive in the jail. Because browsers send one credential-less request first, keep maxretry at five or higher in ddos or aggressive mode.

Can fail2ban read Traefik JSON logs?#

Not with the stock filter, which expects Common Log Format. Switch to --accesslog.format=common, or write a custom filter that matches the JSON fields ClientHost and DownstreamStatus, test it with fail2ban-regex, and set a matching datepattern.

Spotted something wrong or out of date? Tell us — every page is checked against the current fail2ban release.