---
title: "Lock down a server and put a WAF in front of your app"
description: "erpc worker locks a server down from the first deploy, and one table in erpc.toml puts a WAF in front of a public API. What it does and what it does not."
canonical: "https://erpc.global/en/doc/erpc-cli/worker-firewall-and-waf/"
locale: "en"
---

# Lock down a server and put a WAF in front of your app

From the first deploy, `erpc worker` lets SSH in only from your address and drops every other inbound connection. A `[waf]` table puts a filtering proxy in front of your API.

This page describes ERPC CLI `v0.1.0-rc.4`, a pre-release.

## The firewall

A deploy sets the server's firewall before the app first starts:

- SSH only from `firewall.ssh_allow` and the address the deploy comes from;
- every other inbound connection dropped, except `run.port` from `firewall.app_allow`; for an app behind a WAF, the WAF's port in its place, and only where the WAF on the server already runs the rules `[waf]` now makes ([below](https://erpc.global/en/doc/erpc-cli/worker-firewall-and-waf/#put-a-waf-in-front-of-your-app)).

Each change runs under a 120-second rollback timer and is kept only once a fresh connection gets in, so a mistaken change cannot lock you out.

The allow list outlives the address it was made from, though: if your home address changes, the way back in is ERPC support for an ERPC server, or the provider's console for a server of your own. Keep a second address you can reach the server from (an office, a VPN exit) in `firewall.ssh_allow` before you trust a home one.

If your address changes, add every fixed address your admins use:

```bash
erpc worker firewall allow --to ssh 203.0.113.10 198.51.100.7
```

| Command | What it does |
| --- | --- |
| `erpc worker firewall list` | Shows who may reach the server: `erpc.toml` beside the server's table |
| `erpc worker firewall allow --to ssh\|app <addresses>` | Lets addresses in, and applies it. SSH is allowed from single addresses; a network is accepted only with `--to app` |
| `erpc worker firewall deny --to ssh\|app <addresses>` | Stops letting them in |
| `erpc worker firewall sync` | Makes the server's firewall what `erpc.toml` says again |

## Put a WAF in front of your app

Add one table to `erpc.toml`:

```toml
[waf]
port = 8080
```

`erpc worker deploy` then runs an HTTP reverse proxy on the server, in a systemd unit of its own (`erpc-waf-<app>-<env>`): an unprivileged dynamic user with no key, no secret and nothing of the worker's, held to 1 GiB of memory. It listens on `waf.port`, reads each request before the app does, refuses what its rules name, and forwards the rest to the app on `127.0.0.1:<run.port>`.

- **The app stays on loopback.** Its own port is never opened from outside. `erpc worker api` and the health check reach the app on loopback, past the WAF.
- **Who may connect is still the firewall's list**, and the WAF's port is the one place that list may say everyone:

  ```sh
  erpc worker firewall allow --to app 0.0.0.0/0     # and ::/0
  ```

  With nobody listed, the WAF listens and nobody is let in, and the deploy says so.
- **The port is opened once the WAF listens on it**, never before. A deploy whose WAF does not come up fails and says why, with the app still running.
- **When you change `[waf]`,** the port is not opened to anyone new until a deploy has put the new rules in place.
- **Taking `[waf]` out** of `erpc.toml` and deploying removes the WAF from the server.

## WAF settings

Only `port` is required. Every key, with its default:

```toml
[waf]
port = 8080          # the public port, 1024 or above; the app keeps [run] port
methods = ["GET", "POST", "PUT", "DELETE"]
allow_paths = []     # every path
deny_paths = []
max_body_bytes = 1048576
max_header_bytes = 16384
rate = { per_second = 50, burst = 100 }
max_connections_per_address = 32
ban = { after = 100, window_secs = 60, for_secs = 600 }
block_probes = true
header_timeout_secs = 10
body_timeout_secs = 30
upstream_timeout_secs = 30
new_connections = { per_second = 50, burst = 100 }
```

| Key | What it does | What is refused gets |
| --- | --- | --- |
| `methods` | The methods forwarded, in capitals (16 at most; never `CONNECT`) | 405, with `Allow` |
| `allow_paths` | The paths forwarded; none listed is every path. A pattern is one path, or everything under a prefix when it ends in `*`: `/api/*` is `/api` and all below it. Compared with the path once percent-decoded, case-sensitive | 404 |
| `deny_paths` | Paths refused, written the same way and checked first | 403 |
| `max_body_bytes` | The largest request body, up to 64 MiB | 413 |
| `max_header_bytes` | The largest request line and headers together, 1024 to 65536 | 431 |
| `rate` | Requests a second from one address, and how many at once before the rate applies | 429, with `Retry-After` |
| `max_connections_per_address` | Connections one address holds at once | The connection closed, unanswered |
| `ban` | After `after` blocked requests within `window_secs`, the address is refused for `for_secs` (up to 30 days) | Its connections closed, unanswered |
| `block_probes` | Refuses what scanners ask for (`/.env`, `/.git`, `/wp-login.php` and the like) | 403 |
| `header_timeout_secs` | How long a client has to send a request's head, 1 to 120. Also how long a client that has stopped in the middle of a body, or stopped taking its answer, is waited for | 408 for the head; 400 for a body that stopped; the connection closed for an answer not taken |
| `body_timeout_secs` | How long a request's body may take in all, 1 to 600, not counting the time the WAF waits on the app | 408 |
| `upstream_timeout_secs` | How long the app has to take the connection and to start answering, 1 to 600 | 504; 502 when the app is not listening |
| `new_connections` | The firewall's own limit on new TCP connections a second from one address to the WAF's port, dropped by nftables before the proxy sees them. While nftables' set of addresses is full, an address not in it is not counted, so this limit does not apply to it until the set has room again; the proxy's own limits (`rate`, `max_connections_per_address`, `ban`) still apply | The packet dropped |

`rate`, `max_connections_per_address`, `ban` and `new_connections` are turned off with `false` (`ban = false`), and a key left out is its default. `erpc worker check` says what the `[waf]` table will do, and what is wrong with it, before any deploy.

## What the WAF does with no setting needed

- A request that does not read as one unambiguous HTTP/1 request gets 400, and so does a path that is not in normal form.
- A request to change protocol (`Upgrade`) gets 501.
- Every refusal counts towards the ban, and each refusal ends its connection.
- An address is an IPv4 address, or an IPv6 `/64`.
- The WAF does not forward the headers of the client's connection to the proxy (`Connection` and the headers it names, `Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `Proxy-Connection`, `TE`, `Trailer`), `Expect`, headers whose name has an underscore, or the client's own `Forwarded`, `X-Real-IP` and `X-Forwarded-Proto`. It sets `X-Real-IP` to the address the connection came from and adds that address to the end of `X-Forwarded-For`; only the last entry of `X-Forwarded-For` is the proxy's word.

## See what the WAF does

| Command | Shows |
| --- | --- |
| `erpc worker status` | The WAF's port, where it forwards, since when, and the counts since then |
| `erpc worker firewall list` | Who may reach the WAF's port, and what was blocked, by rule |
| `erpc worker logs --waf` | Each blocked request: the rule, the status, the client's address, the method and the first 200 bytes of the target |

A forwarded request leaves no log line, and no header or body is ever logged; a blocked request's query is, as far as the 200 bytes reach. What a client sends cannot write on your terminal: the WAF writes its journal as printable ASCII.

## What the WAF does not do

- **No TLS.** It speaks plain HTTP/1.1 on its port.
- **No HTTP/2:** it answers 400 to it.
- **No WebSocket**, nor any other `Upgrade`: 501.
- **No look inside bodies.** A body is measured, never read. The probe list is short and a noise filter, not a guarantee: the app still checks its input.
- **No authentication.** Who may call the app is the app's to decide, and the firewall's list.
- **One address is one client.** Clients behind one NAT, proxy or CDN share a rate and a ban; a client with many addresses has as many rates.
- **Bans and counts are forgotten** when the WAF restarts.
- **It remembers at most 8192 addresses** and holds at most 1024 connections at once.
- **It listens on IPv4 only,** so an IPv6 entry in `app_allow` reaches nothing yet.
- **A path is compared as the bytes it decodes to.** An app that reads paths more loosely (ignoring case, `;` parameters, decoding twice) can be reached under a name `deny_paths` does not cover. `allow_paths` has no such gap: what is not listed is not forwarded.
- **A slow reader is served to its end.** A client that keeps taking an answer, however slowly, is served to the end.
- **A flood can cut off a large upload.** The WAF runs on one thread, so a flood can cut off a large upload with 408. An app that takes large bodies raises `body_timeout_secs` together with `max_body_bytes`.

[The worker guide](https://github.com/elsoul/erpc-cli/blob/v0.1.0-rc.4/docs/worker.md) has every rule, including when a change to `app_allow` is held until the next deploy.
