Guides

fail2ban and Docker: run it in a container or protect containers

Whether fail2ban runs on the host or in its own container, two things decide whether it works with Docker: the ban must land in the chain container traffic actually passes, and fail2ban must be able to read the container's logs.

Updated 13 min readChecked against fail2ban 1.1.1

To make fail2ban block traffic to a Docker container, ban in the DOCKER-USER chain instead of INPUT: set banaction = iptables-allports and chain = DOCKER-USER in the jail. Published ports are forwarded to the container and never pass INPUT, where fail2ban bans by default. To run fail2ban itself in Docker, use crazymax/fail2ban or lscr.io/linuxserver/fail2ban with network_mode: host and the NET_ADMIN and NET_RAW capabilities.

/etc/fail2ban/jail.d/myapp.local
[myapp]
enabled   = true
backend   = auto
filter    = myapp
logpath   = /srv/myapp/logs/access.log
banaction = iptables-allports
chain     = DOCKER-USER
bash
sudo fail2ban-client reload
sudo iptables -L DOCKER-USER -n --line-numbers

Choose your setup: fail2ban on the host or in a container#

Both setups ban by editing the host's firewall, and both use the same jails, chains and log rules. The difference is packaging:

Feature fail2ban on the host fail2ban in a container
Install Distro package (Debian, Ubuntu, RHEL) crazymax/fail2ban or linuxserver/fail2ban image
Config /etc/fail2ban/ Mounted volume (/data or /config)
Reads logs Host paths and the journal directly Only what you mount into the container
Client sudo fail2ban-client … docker exec fail2ban fail2ban-client …
Updates apt/dnf upgrade docker compose pull

Pick the container if the rest of your stack already lives in Compose and you want fail2ban versioned the same way. Pick the host package if you also protect host services that log to the journal, such as SSH on Debian 12 and newer, because a container needs extra work to read the journal.

Whichever you choose, jails for containerized apps use chain = DOCKER-USER (or the nftables forward hook) and jails for host services such as sshd use the default INPUT.

Run fail2ban in Docker with Compose#

fail2ban bans by adding iptables or nftables rules. A container normally has its own network namespace with its own, empty firewall, so rules added there don't touch traffic to the host or to other containers. Three settings fix that:

  • network_mode: host puts the container in the host's network namespace, so its rules are the host's rules.
  • NET_ADMIN allows changing firewall rules; NET_RAW allows the raw sockets the iptables tools need.
  • Read-only log mounts give it something to read. Log paths in jails are paths inside the container.

Without host networking or the capabilities, bans show up in fail2ban-client status and block nothing.

Which fail2ban Docker image to use#

Feature crazymax/fail2ban lscr.io/linuxserver/fail2ban
Registry Docker Hub, ghcr.io/crazy-max/fail2ban lscr.io (also Docker Hub)
Base Alpine (default tags); Debian (debian tags) LinuxServer.io base image
Config volume /data (jail.d/, filter.d/, action.d/, database) /config
Jails None until you add files to /data/jail.d/ Extra filters for self-hosted apps; all jails disabled
Default ban actions iptables-multiport / iptables-allports iptables-multiport
iptables mode IPTABLES_MODE = auto (default), nft or legacy Handled by the image
systemd journal Debian tags with the host journal mounted Log files only

Pick crazymax for stock fail2ban where you write the jails yourself. Pick linuxserver if you already run their containers (SWAG, Nextcloud, Vaultwarden…) and want their ready-made filters for logs under /remotelogs. Check the bundled version with docker exec fail2ban fail2ban-client --version.

crazymax/fail2ban compose example#

compose.yaml
services:
  fail2ban:
    image: crazymax/fail2ban:latest
    container_name: fail2ban
    network_mode: host
    cap_add:
      - NET_ADMIN
      - NET_RAW
    volumes:
      - ./data:/data
      - /var/log:/var/log:ro
    environment:
      - TZ=Europe/Berlin
      - F2B_LOG_LEVEL=INFO
    restart: always

Create the folders and a first jail next to the compose file:

bash
mkdir -p data/jail.d data/filter.d data/action.d
data/jail.d/sshd.local
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 192.168.1.0/24
bantime  = 1h
findtime = 10m
maxretry = 5

[sshd]
enabled  = true
chain    = INPUT
port     = ssh
backend  = auto
logpath  = /var/log/auth.log
bash
docker compose up -d
docker compose logs -f fail2ban

What the image documents:

  • Files in /data/jail.d/, /data/filter.d/ and /data/action.d/ are layered on the stock config, and a file with an existing name overrides the built-in one. The usual jail.local load order applies.
  • F2B_LOG_TARGET defaults to STDOUT, so docker compose logs shows bans. F2B_DB_PURGE_AGE (default 1d) sets how long bans stay in the database.
  • Restart after config changes: docker compose restart fail2ban.
  • The image uses backend = auto for SSH and Postfix, so sshd needs /var/log/auth.log. Debian 12 and newer, Fedora and Arch don't write that file by default: install rsyslog on the host, or use a debian tag with the host journal mounted read-only (the image's examples/systemd).

linuxserver/fail2ban compose example#

compose.yaml
services:
  fail2ban:
    image: lscr.io/linuxserver/fail2ban:latest
    container_name: fail2ban
    network_mode: host
    cap_add:
      - NET_ADMIN
      - NET_RAW
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Etc/UTC
    volumes:
      - ./config:/config
      - /var/log:/var/log:ro
      - /srv/npm/data/logs:/remotelogs/npm:ro
    restart: unless-stopped

On first start the container fills /config with the fail2ban config tree. The *.conf files are replaced on every restart, so put changes in .local files, for example jail.local:

jail.local
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16

[sshd]
enabled = true
chain   = INPUT

Mount whole log folders under /remotelogs/<app>, not single files. A single-file bind mount breaks when the app rotates its log, because the container keeps the old inode.

Protect published container ports (DOCKER-USER)#

When you publish a port (-p 8080:80), Docker adds a DNAT rule in nat PREROUTING that rewrites the destination to the container's address and port. From then on the packet is forwarded: it passes the FORWARD chain on its way to the container's bridge. fail2ban's defaults all sit on the input path:

  • iptables-* actions use chain = INPUT.
  • The nftables action creates table inet f2b-table with a chain on hook input.
  • The ufw action adds rules to ufw's input chains, which Docker's published ports bypass as well.

So the jail matches, fail2ban-client status lists the IP, the firewall rule exists, and the container keeps answering. Docker's answer is the DOCKER-USER chain: FORWARD jumps to it before Docker's own accept rules, and Docker never flushes it. Where the ban goes depends on where the service runs:

Service runs… Traffic path Jail setting
On the host (sshd, host nginx) INPUT default chain = INPUT
In a container with published ports FORWARD → DOCKER-USER chain = DOCKER-USER
Docker 29+ with the nftables firewall backend nftables forward hook chain_hook=forward (see below)
Docker before 17.06 (no DOCKER-USER) FORWARD chain = FORWARD

Ban in DOCKER-USER with iptables#

/etc/fail2ban/jail.d/docker-apps.local
[nginx-http-auth]
enabled   = true
backend   = auto
logpath   = /srv/web/logs/error.log
banaction = iptables-multiport
chain     = DOCKER-USER
port      = http,https

The jail's chain reaches the action through action_. iptables-multiport creates the chain f2b-<jail>, inserts a jump for the listed ports at the top of DOCKER-USER, and adds a REJECT per banned address. If you're unsure of the container port, or the app uses several, use banaction = iptables-allports: it blocks the address for all forwarded TCP traffic. Either way the ban applies to every container on those ports, not only the one whose log triggered it.

fail2ban 1.1.1 rewrote the iptables action to accept several chains, so one jail can cover a service reachable both on the host and in a container: chain = INPUT,DOCKER-USER. On 1.1.0 and older, use one chain per jail.

This works with iptables-legacy and iptables-nft, as long as fail2ban and Docker use the same one; Debian, Ubuntu and RHEL default to iptables-nft for both. Your other jails can keep the distro's default nftables banaction, because the setting is per jail.

DOCKER-USER exists only once dockerd has started. If fail2ban restores bans at boot before that, the iptables commands fail. Order the services:

bash
sudo systemctl edit fail2ban
override.conf
[Unit]
After=docker.service

Ban Docker traffic with nftables (forward hook)#

Setting chain = DOCKER-USER with the nftables action doesn't help: nftables would just name its own chain DOCKER-USER, still on the input hook. Give the jail its own chain on the forward hook instead:

/etc/fail2ban/jail.d/docker-apps.local
[nginx-http-auth]
enabled   = true
backend   = auto
logpath   = /srv/web/logs/error.log
banaction = nftables[type=allports, chain_hook=forward]
chain     = f2b-forward

The nftables action then creates chain f2b-forward { type filter hook forward priority -1; } in inet f2b-table; fail2ban-client -d shows the command. Priority -1 runs before Docker's filter chains at priority 0, and a reject is final, so a later accept can't undo it. Use a separate chain name: the default f2b-chain is already on the input hook for your other jails, and one chain can't sit on two hooks. The chain_hook option exists in 1.0.2 and later, so this works with every current distro package.

Docker 29 added an experimental native nftables firewall backend ("firewall-backend": "nftables" in daemon.json). In that mode there is no DOCKER-USER chain, and Docker's documentation recommends exactly this: your own table with a base chain on the forward hook. If you've switched, use the nftables variant.

Read Docker container logs#

fail2ban can only ban what it reads, and a container's stdout isn't a file on the host. Three options:

Method How Good for
Bind-mounted log directory Mount the app's log directory on the host, point logpath at it Apps that write log files (nginx, NPM, Nextcloud, Vaultwarden with LOG_FILE, a Minecraft server's /data/logs)
journald log driver backend = systemd + journalmatch = CONTAINER_NAME=… Apps that only log to stdout
Docker's json-file logs logpath = /var/lib/docker/containers/<id>/<id>-json.log Last resort: lines are JSON-wrapped and the path changes on every re-create

If fail2ban runs in a container, mount the host directory into it read-only as well. If you use the json-file logs, test your filter against them with the regex tester first.

Bind-mount the log directory#

The official nginx image symlinks its log files to stdout and stderr. Mounting a host directory over /var/log/nginx replaces the symlinks, and nginx writes real files:

compose.yaml
services:
  web:
    image: nginx:stable
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./conf.d:/etc/nginx/conf.d:ro
      - /srv/web/logs:/var/log/nginx

/srv/web/logs/error.log and access.log are now on the host, and any nginx filter works with logpath = /srv/web/logs/error.log. Add a host logrotate rule for the files.

Read container stdout from the journal#

Switch the container to the journald log driver; docker logs keeps working:

compose.yaml
services:
  jellyfin:
    image: jellyfin/jellyfin
    logging:
      driver: journald

Each line lands in the journal with fields such as CONTAINER_NAME. Install python3-systemd, then select the lines with journalmatch:

/etc/fail2ban/jail.d/jellyfin.local
[jellyfin]
enabled      = true
backend      = systemd
journalmatch = CONTAINER_NAME=jellyfin
filter       = jellyfin
banaction    = iptables-allports
chain        = DOCKER-USER

With the systemd backend, fail2ban hands the filter lines shaped like <hostname> <identifier>[<pid>]: <message>, so the regex must not be anchored to the start of the app's own message; the filter in the Jellyfin guide starts with ^.* for this reason. The name must match docker ps exactly: Compose names containers <project>-<service>-1 unless you set container_name.

Log the real client IP#

If every log line shows the bridge gateway (172.17.0.1 on the default bridge, or your Compose network's gateway), connections reach the app through docker-proxy or a reverse proxy container, for example IPv6 clients forwarded to an IPv4-only container. fail2ban would then ban the proxy. Fix the logging first: see fail2ban bans the proxy IP and, for NPM, the Nginx Proxy Manager guide.

Complete example: nginx in Docker with basic auth#

With the nginx compose file above (logs in /srv/web/logs, ports 80 and 443 published, container ports identical):

/etc/fail2ban/jail.d/web.local
[nginx-http-auth]
enabled   = true
backend   = auto
logpath   = /srv/web/logs/error.log
banaction = iptables-multiport
chain     = DOCKER-USER
port      = 80,443
maxretry  = 5
bantime   = 1h

[nginx-botsearch]
enabled   = true
backend   = auto
logpath   = /srv/web/logs/error.log
banaction = iptables-multiport
chain     = DOCKER-USER
port      = 80,443

backend = auto matters on Ubuntu 24.04, whose package sets backend = systemd for every jail and would otherwise ignore logpath.

bash
sudo fail2ban-client reload
sudo fail2ban-regex /srv/web/logs/error.log nginx-http-auth
sudo fail2ban-client status nginx-http-auth

fail2ban Docker not working: banned but still reachable#

Start with these checks:

bash
sudo fail2ban-client banned 203.0.113.42          # does fail2ban hold the ban?
sudo fail2ban-client get nginx-http-auth actions  # which action?
sudo iptables -L DOCKER-USER -n --line-numbers    # does DOCKER-USER jump to f2b-*?
sudo nft list ruleset | grep -B3 -A3 "hook forward"
docker ps --format '{{.Names}} {{.Ports}}'        # which ports are published?

The usual causes, in order:

  1. Wrong chain. The ban sits in INPUT or in f2b-table on hook input while the service is a container. Use DOCKER-USER or the forward hook.
  2. Wrong port. The jail lists the published port instead of the container port. Switch to an allports action.
  3. fail2ban container without host networking or NET_ADMIN. The rules went into the container's own namespace.
  4. No DOCKER-USER at ban time. fail2ban started before Docker (add After=docker.service), or Docker uses its nftables backend.
  5. The log shows the gateway IP, so fail2ban bans 172.17.0.1 instead of the client.

A fail2ban container's iptables may use the legacy backend while the host uses nf_tables, or the other way round. The kernel evaluates both, so bans still work, but you only see them with the matching tool on the host.

To confirm the fix, ban an address you control (a phone on mobile data works) and load the site from it:

bash
sudo fail2ban-client set nginx-http-auth banip 198.51.100.7
sudo iptables -L f2b-nginx-http-auth -n           # iptables variant
sudo nft list chain inet f2b-table f2b-forward    # nftables variant

The request must be refused or time out. Then lift the ban with sudo fail2ban-client unban 198.51.100.7.

Unban an IP in fail2ban running in Docker#

On a host install, use the normal unban commands. In a container, run the same client inside it:

bash
docker exec fail2ban fail2ban-client status
docker exec fail2ban fail2ban-client status sshd
docker exec fail2ban fail2ban-client set sshd unbanip 203.0.113.42
docker exec fail2ban fail2ban-client unban 203.0.113.42
docker compose exec fail2ban fail2ban-client unban --all

Replace fail2ban with your container name from docker ps. To see the rules on the host:

bash
sudo iptables -S DOCKER-USER
sudo iptables -S INPUT | grep f2b

Jails and the ban database live in the mounted volume, so they survive docker compose pull && docker compose up -d.

FAQ#

Can fail2ban run in a Docker container?#

Yes. Use crazymax/fail2ban or lscr.io/linuxserver/fail2ban with network_mode: host and the NET_ADMIN and NET_RAW capabilities, and mount the logs it should read. The container then edits the host's firewall, so bans protect the host and other containers. Without host networking the bans only apply inside the container's own network namespace.

What is the best fail2ban Docker image?#

The two maintained images are crazymax/fail2ban (stock fail2ban, config in /data, Alpine or Debian tags) and lscr.io/linuxserver/fail2ban (config in /config, extra filters for self-hosted apps, logs under /remotelogs). Both work. Choose linuxserver if you already run their containers, such as SWAG; choose crazymax for a plain setup.

Why doesn't fail2ban block traffic to my Docker container?#

Published ports are DNAT-ed and forwarded, so packets pass the FORWARD chain, not INPUT, where fail2ban bans by default. Set chain = DOCKER-USER with an iptables banaction such as iptables-allports, or use banaction = nftables[type=allports, chain_hook=forward] with chain = f2b-forward.

What is the DOCKER-USER chain?#

DOCKER-USER is an iptables chain Docker creates for administrator rules. FORWARD jumps to it before Docker's own rules, and Docker never flushes it, so a REJECT there stops traffic to published ports. Packets in it are already DNAT-ed, so port matches see the container port. Docker's nftables backend has no DOCKER-USER chain.

Should I use the DOCKER-USER or INPUT chain?#

Use chain = DOCKER-USER for services in containers with published ports, because that traffic goes through FORWARD. Use the default INPUT for services on the host itself, such as sshd. On fail2ban 1.1.1 one jail can use both: chain = INPUT,DOCKER-USER.

Does UFW protect Docker containers?#

Not for published ports. Docker's DNAT and FORWARD rules bypass ufw's input rules, so neither ufw nor banaction = ufw blocks container traffic. Keep ufw for host services and use DOCKER-USER or an nftables forward chain for jails that protect containers.

How do I unban an IP in fail2ban running in Docker?#

Run the client inside the container: docker exec fail2ban fail2ban-client set <jail> unbanip <IP>, or docker exec fail2ban fail2ban-client unban <IP> to remove it from all jails and the database. Replace fail2ban with your container name from docker ps.

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