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

> fail2ban and Docker: run it in a container with crazymax or linuxserver compose files, or ban in DOCKER-USER so published ports are blocked. Logs and fixes.
>
> Source: https://fail2ban.dev/guides/docker/ · Updated 2026-10-26 · Checked against fail2ban 1.1.1
> fail2ban.dev is an independent guide, not the official fail2ban project.

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.

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

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](/install/debian/), [Ubuntu](/install/ubuntu/), [RHEL](/install/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

```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
```

```bash
```
[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
```
docker compose up -d
docker compose logs -f fail2ban
```

- 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](/config/jail-local/) 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

```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
```

```ini
[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
```

## 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` |

> **Match the container port, not the published port**
DNAT happens before `DOCKER-USER` and the forward hook, so the destination port there is the **container** port. For `-p 8080:80`, a jail with `port = 8080` matches nothing; use `port = 80`, or an allports action that doesn't care.

### Ban in DOCKER-USER with iptables

```ini
[nginx-http-auth]
enabled   = true
backend   = auto
logpath   = /srv/web/logs/error.log
banaction = iptables-multiport
chain     = DOCKER-USER
port      = http,https
```

fail2ban 1.1.1 rewrote the [iptables action](/guides/iptables/) 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](/config/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
```
[Unit]
After=docker.service
```

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:

```ini
[nginx-http-auth]
enabled   = true
backend   = auto
logpath   = /srv/web/logs/error.log
banaction = nftables[type=allports, chain_hook=forward]
chain     = f2b-forward
```

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](/guides/minecraft/#minecraft-in-docker-itzg-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](/tools/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:

```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
```

### Read container stdout from the journal

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

```yaml
services:
  jellyfin:
    image: jellyfin/jellyfin
    logging:
      driver: journald
```

```ini
[jellyfin]
enabled      = true
backend      = systemd
journalmatch = CONTAINER_NAME=jellyfin
filter       = jellyfin
banaction    = iptables-allports
chain        = DOCKER-USER
```

### 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](/troubleshooting/reverse-proxy-ip/) and, for NPM, the [Nginx Proxy Manager guide](/guides/nginx-proxy-manager/).

## 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):

```ini
[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
```

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

Start with these checks:

```bash
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?
```

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 iptables -L f2b-nginx-http-auth -n           # iptables variant
sudo nft list chain inet f2b-table f2b-forward    # nftables variant
```

## Unban an IP in fail2ban running in Docker

On a host install, use the normal [unban commands](/unban-ip/). In a container, run the same client inside it:

```bash
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
```

```bash
sudo iptables -S INPUT | grep f2b
```

## 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`.
