# fail2ban with Caddy

> Use fail2ban with Caddy: write a JSON access log, a tested filter for client_ip and status 401/403/404, a datepattern for the epoch ts field, and Docker bans.
>
> Source: https://fail2ban.dev/guides/caddy/ · Updated 2026-10-25 · Checked against fail2ban 1.1.1
> fail2ban.dev is an independent guide, not the official fail2ban project.

To use fail2ban with Caddy, enable a JSON access log to a file (`log { output file /var/log/caddy/access.log }`), create a filter that matches `"client_ip":"<ADDR>"` followed by `"status":401`, `403` or `404`, set `datepattern = "ts":{EPOCH}` so fail2ban reads Caddy's Unix timestamp, and enable a jail with `backend = auto`. The filter below is tested with `fail2ban-regex`.

```ini
[Definition]
failregex = ^\{.*?"client_ip":"<ADDR>".*?"status":(?:<codes>)[,}]
datepattern = "ts":{EPOCH}

[Init]
codes = 401|403|404
```
[caddy-4xx]
enabled  = true
backend  = auto
filter   = caddy-4xx
logpath  = /var/log/caddy/access.log
port     = http,https
maxretry = 15
findtime = 10m
bantime  = 1h
```
sudo fail2ban-client reload
sudo fail2ban-client status caddy-4xx
```

Caddy only logs requests for sites that contain a `log` directive. Add it to each site block you want to protect:

```text
example.com {
	log {
		output file /var/log/caddy/access.log
		format json
	}
	reverse_proxy localhost:8080
}
```
sudo systemctl reload caddy
```

## Step 2: what a Caddy access log entry looks like

Each request is one JSON object on one line. Shortened for readability, a 404 from a scanner looks like this:

```json
```

- `ts` — Unix time as a float (the default `time_format` for JSON). `datepattern = "ts":{EPOCH}` tells fail2ban where to find it.
- `request.client_ip` — the client address (Caddy 2.7+). It equals `remote_ip` unless the connection came from a trusted proxy, in which case it's the address from the forwarded header.
- `status` — the response code. Caddy logs 4xx responses at level `info` and 5xx at `error`, so don't filter on `level`.

Request headers are logged inside the entry, but JSON escaping turns any quote a client sends into `\"`, so a crafted `User-Agent` can't fake a `"status":404` or `"client_ip"` field. We checked that with a test line whose user agent contained `"status":404`: it didn't match.

## Step 3: test the filter

```bash
```

Variants via the `codes` parameter, without new files:

```ini
filter = caddy-4xx[codes="401|403"]    # auth failures and forbidden
```

## Behind another proxy or Cloudflare

If Caddy sits behind a load balancer or Cloudflare, `remote_ip` is the proxy. Tell Caddy which proxies to trust, so `client_ip` carries the real visitor:

```text
{
	servers {
		trusted_proxies static 10.0.0.5/32 192.168.1.0/24
		trusted_proxies_strict
	}
}
```

## Caddy in Docker

With the official `caddy` image, mount a log directory to the host and keep the same `log` block:

```yaml
services:
  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./caddy_data:/data
      - /var/log/caddy:/var/log/caddy
```

```ini
[caddy-4xx]
enabled   = true
backend   = auto
filter    = caddy-4xx
logpath   = /var/log/caddy/access.log
banaction = iptables-allports
chain     = DOCKER-USER
maxretry  = 15
bantime   = 1h
```

## FAQ

### Does fail2ban have a filter for Caddy?

No, fail2ban 1.1.1 ships no Caddy filter. Caddy logs JSON, so write a small one: `failregex = ^\{.*?"client_ip":"<ADDR>".*?"status":(?:401|403|404)[,}]` with `datepattern = "ts":{EPOCH}`, then point a jail at the access log file with `backend = auto`.

### Where does Caddy write its access log?

Nowhere by default. Add a `log` block with `output file /var/log/caddy/access.log` to each site in the Caddyfile and reload Caddy. Without `output file`, logs go to stderr, which ends up in the systemd journal or `docker logs`, where fail2ban would need a different setup.

### How does fail2ban read the timestamp in Caddy JSON logs?

Caddy's default JSON time format is Unix seconds as a float in the `ts` field. Set `datepattern = "ts":{EPOCH}` in the filter. If you changed `time_format` in Caddy (for example to `iso8601`), adjust the datepattern to match or remove it to let fail2ban detect the date.

### Should I use client_ip or remote_ip in the filter?

Use `client_ip` on Caddy 2.7 and newer. It's the real visitor address when the request came through a proxy you listed in `trusted_proxies`, and equals `remote_ip` otherwise. `remote_ip` is always the direct TCP peer, which behind a proxy is the proxy itself.
