Guides

fail2ban with Caddy

Caddy logs JSON, and fail2ban has no stock Caddy filter. A three-line filter fixes that: it reads client_ip and status from each entry and takes the time from the ts epoch field.

Updated 5 min readChecked against fail2ban 1.1.1

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.

/etc/fail2ban/filter.d/caddy-4xx.conf
[Definition]
failregex = ^\{.*?"client_ip":"<ADDR>".*?"status":(?:<codes>)[,}]
datepattern = "ts":{EPOCH}

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

Step 1: write Caddy's access log to a file#

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

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

JSON is the format for file output; format json just makes it explicit. Caddy rolls the file itself (by default at 100 MiB, keeping 10 files), and fail2ban follows the rotation. Several sites can share one file, or use one file each and list them all in logpath.

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
{"level":"info","ts":1792953370.4529164,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"203.0.113.42","remote_port":"51234","client_ip":"203.0.113.42","proto":"HTTP/2.0","method":"GET","host":"example.com","uri":"/.env","headers":{"User-Agent":["Mozilla/5.0"]}},"bytes_read":0,"user_id":"","duration":0.000412,"size":0,"status":404,"resp_headers":{"Server":["Caddy"]}}

The fields the filter uses:

  • 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
sudo fail2ban-regex /var/log/caddy/access.log /etc/fail2ban/filter.d/caddy-4xx.conf

On our three-line sample (a 404, a 401 and a 200 with the fake header), it reported 2 matched, 1 missed and the date template hit "ts":{EPOCH} on every line. If you see 0 matched, check that you're on Caddy 2.7 or newer (caddy version); older versions have no client_ip, so change the filter to "remote_ip":"<ADDR>". You can experiment with the expression in the regex tester, and the fail2ban-regex guide explains every part of the output.

Variants via the codes parameter, without new files:

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

A basic_auth failure in Caddy is simply a 401 in this log, so codes="401" with maxretry = 5 makes a brute-force jail for protected sites. For 404s keep maxretry high (15–20): a missing image on a busy page creates 404s for real visitors too.

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:

/etc/caddy/Caddyfile
{
	servers {
		trusted_proxies static 10.0.0.5/32 192.168.1.0/24
		trusted_proxies_strict
	}
}

Caddy reads X-Forwarded-For by default; add client_ip_headers CF-Connecting-IP inside servers for Cloudflare and list Cloudflare's ranges (or use a trusted-proxies module that fetches them). trusted_proxies_strict parses the header from the right, which Caddy recommends behind Cloudflare because the left-most entry can be spoofed. With Cloudflare in front, also ban at Cloudflare's edge rather than in your firewall — the Cloudflare guide explains why and how. General background: fail2ban bans the proxy IP.

Caddy in Docker#

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

compose.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

Docker forwards published ports to the container, bypassing the INPUT chain where fail2ban bans by default. Ban in DOCKER-USER instead:

/etc/fail2ban/jail.d/caddy.local
[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

By default the action blocks TCP only, but HTTP/3 runs over UDP 443. If you publish 443/udp, add protocol = tcp,udp to the jail: in fail2ban 1.1.1 the iptables actions loop over a comma-separated protocol list. The full Docker background, including the nftables variant, is in fail2ban with Docker containers.

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.

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