# How to write a custom fail2ban action

> Write a custom fail2ban action: actionban, actionunban, every tag like <ip>, <name>, <failures>, <matches>, plus ntfy, Discord, Telegram and API examples.
>
> Source: https://fail2ban.dev/actions/custom/ · Updated 2026-10-25 · Checked against fail2ban 1.1.1
> fail2ban.dev is an independent guide, not the official fail2ban project.

To create a custom fail2ban action, save a file such as **`/etc/fail2ban/action.d/ntfy.conf`** with a `[Definition]` section whose **`actionban`** (and optionally `actionunban`, `actionstart`, `actionstop`) contains shell commands. Use tags like **`<ip>`**, **`<name>`** (the jail) and **`<failures>`**, which fail2ban fills in. Add parameters in `[Init]`, then add the action to a jail with `action = %(action_)s` plus a line `ntfy[url="…"]`.

```ini
[Definition]
norestored  = 1
actionban   = curl -fsS -m 10 -H "Title: fail2ban <name>: banned <ip>" -d "<ip> banned in jail <name> after <failures> failures on <fq-hostname>" "<url>"
actionunban =

[Init]
url     = https://ntfy.sh/my-secret-topic
timeout = 15
```
[sshd]
enabled = true
action  = %(action_)s
          ntfy[url="https://ntfy.sh/my-secret-topic"]
```

## Anatomy of an action file

```ini
[INCLUDES]
before = helpers-common.conf      ; optional shared definitions

[Definition]
actionstart  = <commands run when the jail starts>
actionstop   = <commands run when the jail stops>
actioncheck  = <commands run before each ban to verify the setup>
actionban    = <commands run for each ban>
actionunban  = <commands run when a ban ends>
actionflush  = <commands to remove all bans at once (on stop)>

[Init]
# parameters, overridable from the jail: example[param1=value]
param1  = default
timeout = 60
```
|---|---|
| `actionstart` | When the jail starts (firewall actions with IPv4/IPv6 variants start on demand, at the first ban). Create chains, sets, files. |
| `actionstop` | When the jail stops or fail2ban shuts down. Remove what `actionstart` created. |
| `actioncheck` | Before each ban. A non-zero exit means the environment broke (e.g. a firewall reload wiped the chain): fail2ban re-runs `actionstop` + `actionstart` (or `actionrepair` if defined). |
| `actionban` | For every new ban, with the IP and ticket tags filled in. |
| `actionunban` | When a ban expires or you unban manually. |
| `actionflush` | On stop, to remove all bans at once instead of unbanning one by one. |
| `actionreban` | To re-apply an active ban after the action was restarted or repaired; defaults to `actionban`. |
| `actionprolong` | When `bantime.increment` extends a ban that's already active. |
| `actionrepair`, `actionreload` | Repair after a failed `actioncheck`; run on reload. |

Flags in `[Definition]`:

- **`norestored = 1`**: skip this action for bans restored from the database after a restart. Every notification action should set it, or you get a flood of messages on each restart. The stock mail actions do.
- **`actionstart_on_demand = true|false`**: run `actionstart` at the first ban instead of at jail start.

Rules for the commands:

- They run through `/bin/sh` as **root**. Treat any value from logs as hostile.
- Multiple commands: one per line, continuation lines indented, no blank lines. They run as one shell script.
- Exit code 0 means success; anything else is logged as an error.
- Each command is killed after `timeout` seconds (default 60). Always give network calls their own shorter timeout (`curl -m 10`).
- `%` must be written `%%` (config interpolation).

## Tags you can use

Tags are written `<tag>` and replaced before the command runs. The list was checked against `fail2ban/server/actions.py` in 1.1.1.

### Ticket tags (actionban, actionunban, actioncheck)

| Tag | Value |
|---|---|
| `<ip>` | The banned IP address, e.g. `203.0.113.42` or `2001:db8::25` |
| `<family>` | `inet4` or `inet6` |
| `<fid>` | Failure ID: the IP, or the `<F-ID>` value for ID-based filters |
| `<ip-rev>` | Reverse-DNS name of the IP (PTR), empty if none |
| `<ip-host>` | Host name resolved from the IP |
| `<failures>` | Number of failures that led to this ban |
| `<time>` | Unix time of the ban |
| `<bantime>` | Ban duration in seconds for this ban (grows with incremental bans) |
| `<bancount>` | How many times this IP has been banned |
| `<restored>` | `1` if the ban was restored from the database, else `0` |
| `<matches>` | The matched log lines (up to `maxmatches`) |
| `<ipmatches>`, `<ipjailmatches>` | Matched lines for this IP from the database, all jails / this jail |
| `<ipfailures>`, `<ipjailfailures>` | Failure counts for this IP from the database, all jails / this jail |
| `<F-USER>`, `<F-anything>` | Values captured by the filter with `<F-…>` groups, e.g. the user name |
| `<raw-ticket>` | The raw ticket, for debugging |

### Jail and static tags

| Tag | Value |
|---|---|
| `<name>` | The jail name (unless the jail passes `name=`) |
| `<actname>` | The action's name in the jail |
| `<port>`, `<protocol>`, `<chain>` | Passed by `action_` from the jail's settings |
| `<jail.name>`, `<jail.banned>`, `<jail.banned_total>`, `<jail.found>`, `<jail.found_total>` | Jail statistics at the time of the action |
| `<fq-hostname>`, `<sh-hostname>` | This server's full and short host name |
| `<br>` / `<sp>` | A newline / a space |
| any `[Init]` key | Your parameters, e.g. `<url>` |

### Quoting and escaping

Tags whose values come from logs (`<matches>`, `<F-USER>`, `<ip-host>`…) can contain anything. When a value contains shell special characters, fail2ban doesn't paste it into the command; it passes it as an environment variable (`$f2bV_matches`) and substitutes the variable reference. So:

- **Put such tags in double quotes**: `"<matches>"`. In single quotes the variable isn't expanded and you get the literal text `$f2bV_matches`.
- **Never build JSON by pasting `<matches>` into a string.** A quote in the log line breaks the JSON. Use `jq` or a small script (examples below).

## Example: ntfy push notification

The action at the top of this page posts a message to an [ntfy](https://ntfy.sh) topic. On your phone, subscribe to the same topic. For a self-hosted ntfy with access control, add an `Authorization` header:

```ini
[Definition]
norestored  = 1
actionban   = curl -fsS -m 10 -H "Authorization: Bearer <token>" -H "Title: fail2ban <name>: banned <ip>" -d "<ip> banned in jail <name> after <failures> failures" "<url>"
actionunban =

[Init]
url   = https://ntfy.example.com/fail2ban
token =
timeout = 15
```

Both take a JSON body. `jq` builds it safely, even with log lines inside:

```ini
[Definition]
norestored  = 1
actionban   = jq -n --arg msg "**<name>** banned <ip> after <failures> failures on <fq-hostname>" '{content: $msg}' | curl -fsS -m 10 -H "Content-Type: application/json" -d @- "<webhook>"
actionunban =

[Init]
webhook =
timeout = 15
```
[sshd]
enabled = true
action  = %(action_)s
          discord[webhook="https://discord.com/api/webhooks/ID/TOKEN"]
```

## Example: Telegram bot

```ini
[Definition]
norestored  = 1
actionban   = curl -fsS -m 10 -X POST "https://api.telegram.org/bot<bot_token>/sendMessage" -d chat_id="<chat_id>" --data-urlencode text="[<name>] banned <ip> after <failures> failures on <fq-hostname>"
actionunban =

[Init]
bot_token =
chat_id   =
timeout   = 15
```
[DEFAULT]
action = %(action_)s
         telegram[bot_token="123456789:ABCdef", chat_id="987654321"]
```

## Example: write bans to a file

A simple audit log, one line per ban and unban:

```ini
[Definition]
actionstart = touch <logfile>
actionban   = printf '%%s ban %%s %%s %%s\n' "$(date -u +%%FT%%TZ)" "<name>" "<ip>" "<failures>" >> <logfile>
actionunban = printf '%%s unban %%s %%s\n' "$(date -u +%%FT%%TZ)" "<name>" "<ip>" >> <logfile>

[Init]
logfile = /var/log/fail2ban-bans.log
```

## Example: call your own API

Push bans to a central blocklist service, with an API key and both ban and unban:

```ini
[Definition]
actionban   = curl -fsS -m 10 -X POST -H "Authorization: Bearer <apikey>" -d ip="<ip>" -d jail="<name>" -d seconds="<bantime>" "<api>/bans"
actionunban = curl -fsS -m 10 -X DELETE -H "Authorization: Bearer <apikey>" "<api>/bans/<ip>"

[Init]
api    = https://blocklist.example.com/v1
apikey =
timeout = 20
```

## Python actions

For logic that's awkward in shell, write a Python action, `action.d/<name>.py`, with a class assigned to `Action`. Jail parameters arrive as keyword arguments (strings):

```python
from fail2ban.server.action import ActionBase


class CSVLog(ActionBase):
    def __init__(self, jail, name, path="/var/log/fail2ban-bans.csv"):
        super(CSVLog, self).__init__(jail, name)
        self.path = path
        self.norestored = 1          # skip restored bans

    def ban(self, aInfo):
        with open(self.path, "a") as f:
            f.write("%s,%s,%s\n" % (self._jail.name, aInfo["ip"], aInfo["failures"]))

    def unban(self, aInfo):
        pass


Action = CSVLog
```
[sshd]
enabled = true
action  = %(action_)s
          csvlog.py[path="/var/log/ssh-bans.csv"]
```

## Test your action

**1. Check the parsed commands.** After `sudo fail2ban-client reload`, ask fail2ban what it will run, with parameters filled in:

```bash
sudo fail2ban-client get sshd action ntfy actionban
```

```text
$ sudo fail2ban-client set sshd banip 192.0.2.15 192.0.2.16
2
```
sudo fail2ban-client set sshd unbanip 192.0.2.15 192.0.2.16
```

```bash
```

**4. Run the command yourself.** Copy the output of step 1, replace `<ip>` with `192.0.2.15`, and run it in a root shell. Most problems (missing `curl`/`jq`, wrong URL, quoting) show up immediately.

To try a new jail without touching the firewall, use the stock `dummy` action as the only action; it just writes bans to a file. All stock actions are listed in the [action library](/actions/).

## FAQ

### How do I create a custom action in fail2ban?

Create `/etc/fail2ban/action.d/<name>.conf` with a `[Definition]` section containing `actionban` (and usually `actionunban`) shell commands using tags like `<ip>` and `<name>`, plus defaults for your parameters in `[Init]`. Add it to a jail as an extra line under `action`, then reload and test with `fail2ban-client set <jail> banip`.

### What variables can I use in a fail2ban action?

Ticket tags such as `<ip>`, `<family>`, `<failures>`, `<time>`, `<bantime>`, `<bancount>`, `<matches>`, `<ip-host>` and filter captures like `<F-USER>`; jail tags `<name>`, `<port>`, `<protocol>`; host tags `<fq-hostname>`, `<sh-hostname>`; and any key from the action's `[Init]` section, which jails can override.

### How do I send fail2ban notifications to a webhook?

Write an action whose `actionban` calls `curl` with the webhook URL, for example posting `<ip>` and `<name>` to ntfy, Discord or Slack, and set `norestored = 1` to avoid messages for restored bans on restart. Add it to the jail's `action` after `%(action_)s` so the IP is still banned.

### What is actioncheck used for?

`actioncheck` runs before each ban to confirm the action's environment still exists, for example that the firewall chain wasn't flushed by a firewall reload. If it fails, fail2ban restarts the action (stop and start) before banning. Notification actions usually leave it empty.

### Why does my custom action not run?

Common causes: the jail doesn't list it in `action` (check `fail2ban-client get <jail> actions`), the command fails (look for errors in `/var/log/fail2ban.log`), a program like `curl` isn't installed, a single `%` broke the config, or `norestored = 1` skipped a restored ban.
