One server behind Cloudflare
The launch shape, decided 2026-11-10 to keep the bill small: one server that ends TLS for the API and holds the database, and Cloudflare for everything in front of it. It costs about €5–10 a month: a Hetzner ARM server, Cloudflare's free plan, and R2's free tier for backups. The AWS stack is where it grows to. It runs the same image and the same database engine, so moving there is a lift and shift, not a port.
The compose file is infra/vps/compose.yaml.
What runs where
| Where | Why there | |
|---|---|---|
app.example.com: dashboard, sign-in, marketing pages, Management API |
Cloudflare, proxied, in front of the server | Cloudflare's cache, WAF and DDoS shield. Nothing on these pages needs the visitor's handshake. |
api.example.com: the agent's script, identification, challenges, pulses, Server API |
DNS only, straight to the server's TLS front | The visitor's TLS handshake and HTTP/2 prelude can only be read where TLS ends (below). |
| The TLS front (front/, in Rust) | The server, port 443 | Ends TLS for both names and reads the handshake of every direct connection. |
| The app (web/Dockerfile) | The server, 127.0.0.1:3001 |
Next to its database. |
| Postgres 17 | The server, 127.0.0.1:5432 |
An identification runs about fifteen statements in sequence (measured). A database on another machine would add a network round trip to each of them. |
| Backups | Cloudflare R2 | pg_dump once a day, kept 30 days by a lifecycle rule on the bucket. |
| Certificates | Let's Encrypt, by DNS-01 with a Cloudflare API token | One certificate covers both names. Renewal needs no open port 80. |
Background work (usage reports, retention, domain checks, blocklists) runs on
the app's own timers (web/instrumentation.ts), so the server needs no cron.
Why the API's name is not proxied. Behind Cloudflare's proxy, TLS ends at
Cloudflare. On the Free, Pro and Business plans Cloudflare passes on the TLS
version and cipher but not the handshake itself: JA4 comes only with Bot
Management, which is an Enterprise add-on. The network signal's strongest
checks need the handshake, and so does a receipt's binding to the handshake it
was minted on (ja4ContradictsUa, http2ContradictsUa, tf). So the browser
reaches api.example.com directly, and our own front reads the handshake there.
What that costs: Cloudflare's DDoS shield does not cover api.example.com, and
the server's address is public. The hosting provider's network-level
protection (Hetzner's is included) is what stands in front of it. Customers
who run the CDN proxy send their visitors' traffic through
their own CDN instead, and our front sees only their edge.
The TLS front
The front ends TLS and reads two things before handing the bytes on:
- The ClientHello, before TLS reads it. From it the front computes JA4 (FoxIO's specification, checked against its worked example) and JA3.
- The HTTP/2 prelude, before HTTP/2 reads it. That is the settings, the window update, any priority frames and the pseudo-header order, in Akamai's layout.
It then passes each request to the app with what it read in antibot-front-*
headers: JA4, JA3, ALPN, TLS version, cipher, the HTTP/2 prelude and the
original header order. It removes any of those headers a client sent, and adds
ANTIBOT_FRONT_SECRET, the secret that proves the front wrote them. The app
believes the front's headers only when that secret is present. When the site's
own edge (the CDN proxy) also vouches for a request, the edge's evidence wins:
in that case the front has read the edge's connection, not the visitor's.
Whose handshake it is depends on the connection's address, never on a header.
- A connection from Cloudflare's addresses (
CLOUDFLARE_RANGES, from cloudflare.com/ips) carries Cloudflare's handshake, on the names Cloudflare proxies. Nothing about TLS is passed on, and the visitor's address is the one in Cloudflare'sCF-Connecting-IPheader. - Any other connection carries the client's own handshake and its own address, whatever server name the client asked for. Otherwise a client could ask for the proxied name and avoid being read.
Measured on 2026-11-10 and 2026-11-11. Chrome 155, Edge, Brave and Firefox
loaded pages through the front. Each one's JA4 fell inside its family's
envelope, and Chrome's cipher hash was FoxIO's published 8daaf6152771. Each
HTTP/2 prelude was exactly the browser's:
- Chrome:
1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p - Firefox:
1:65536;2:0;4:131072;5:16384|12517377|0|m,p,a,s
It is written in Rust (tokio, rustls, hyper), because it is the one part
of the service that answers the internet directly. The same front in Node
(proxy/src/front.ts) is its reference. The test
front/test/parity.test.mjs, which cargo test
runs, holds the two to each other in two ways:
- 12,000 hellos and HTTP/2 preludes, generated whole, bent and cut short, must give the same fingerprints from both.
- The same clients, over real handshakes, must reach the app as the same requests, down to the JA4, the cipher and the header order.
The four browsers above gave the same fingerprints through both fronts.
They differ in one place, on purpose. An HTTP/1.1 client may shut its side of the connection once it has asked. The Rust front still answers it, while the Node front drops some of these requests.
The tests were themselves tested with cargo-mutants (2026-11-12). Every change to the front's code that a client or the app could notice fails them. What survives cannot be told apart from outside:
|for^on bits that never overlap.- Comparisons that differ only where the rate limit is off.
- The exact 64 KB boundary of a hello or prelude.
- The name of a signal in the log.
Measured on one core of a Windows development machine (2026-11-11), with a handshake, a request and the app's answer each time:
| Node | Rust | |
|---|---|---|
| CPU per new connection | 1.1 ms | 0.6 ms |
| CPU per request on an open connection | 0.18 ms | 0.06 ms |
| Memory at rest | 70 MB | 12 MB |
| Memory per idle HTTP/2 connection | 47 KB | 38 KB |
| Cores used | one | all (TOKIO_WORKER_THREADS) |
The app spends about 7 ms of CPU on an identification (measured). The front's share of a visit is small either way. What Rust buys is headroom against a flood of handshakes on a name with no Cloudflare in front of it.
An address's limits. A handshake is the costliest thing a client can ask of the front for the least effort of its own. So connections are counted per address before any TLS:
- An address is an IPv4 address, or an IPv6 /64.
- Each may open 50 a second, in bursts of up to 200, and hold 1024 at once.
- A connection over either limit is closed before its hello is read.
The defaults are generous for many visitors behind one carrier's or office's address. Cloudflare's connections are not counted. Refusals are logged once a minute.
A connection that sends nothing for two minutes is asked to close, and what is under way on it finishes first.
| Setting | Read by | Value |
|---|---|---|
ANTIBOT_FRONT_SECRET |
The front and the app | The same value on both, at least 24 characters (openssl rand -hex 32). The front refuses to start without it. |
ANTIBOT_FRONT_CERT, ANTIBOT_FRONT_KEY |
The front | The certificate chain and its key, in PEM. Read again within a minute of either file changing, so a renewal needs no restart. |
ANTIBOT_FRONT_UPSTREAM |
The front | The app: http://127.0.0.1:3001. |
ANTIBOT_FRONT_CLOUDFLARE_RANGES |
The front | Ranges Cloudflare adds after this release, comma separated. A missing range would have Cloudflare's own handshake read as its visitors'. |
ANTIBOT_FRONT_NEW_CONNECTIONS_PER_SECOND, ANTIBOT_FRONT_NEW_CONNECTIONS_BURST, ANTIBOT_FRONT_OPEN_CONNECTIONS |
The front | One address's limits: 50, 200 and 1024 by default. 0 turns the rate or the count off. |
ANTIBOT_FRONT_HELLO_SECONDS, ANTIBOT_FRONT_IDLE_SECONDS |
The front | How long a client has to say hello and finish its handshake (10), and how long a silent connection is kept (120). |
PORT, HOST |
The front | 443, and :: to answer on IPv4 and IPv6. |
ANTIBOT_CLIENT_IP_HEADER |
The app | antibot-front-client-ip. The front always writes that header itself. |
ANTIBOT_REGION, ANTIBOT_REGIONS |
The app | eu and {"eu":{"app":"https://app.example.com","api":"https://api.example.com"}}, so the dashboard's snippets name the API host. |
Setting it up
The server. A Hetzner CAX21 (4 ARM vCPUs, 8 GB) on Ubuntu 24.04, with Docker Engine and its compose plugin. The app's image builds on the server (
next build), and 4 GB is too little for that. In Hetzner's Cloud Firewall, open 443/tcp to all and 22/tcp to your own address only. Everything else listens on loopback.A Docker hardened with
"userns-remap"in/etc/docker/daemon.jsonruns the stack too: every service is in the host's user namespace, which the host's network needs, and the front takes port 443 withsetprivrather than its file capability. SetAPP_UIDin.env(.env.examplesays to what), or the app cannot write its cache.DNS on Cloudflare.
app: A and AAAA records pointing at the server, proxied.api: A and AAAA records pointing at the server, DNS only.- SSL/TLS mode: Full (strict). The front's certificate is a real one.
A Cloudflare API token with Zone → DNS → Edit on the zone, for the certificate's DNS-01 challenge.
An R2 bucket with an API token allowed Object Read & Write on that bucket only, and a lifecycle rule that deletes objects after 30 days.
The settings. In
infra/vps, copy.env.exampleto.envandapp.env.exampletoapp.env, and fill both in. The app's own settings (mail, billing, errors, keys) are the launch ones. Once the app runs, its log names any setting that is missing or wrong:docker compose logs app | grep "production checklist".Start it.
cd infra/vps docker compose up -d --buildThe certificate comes first: the front waits for it. The app migrates the empty database when it first starts.
Look.
- Run
pnpm smoke --url https://app.example.com(the launch checks). - Identify once from a browser through
api.example.com. - Open that event in the dashboard. Its network signal should say the transport was available, and name the browser's JA4.
- Run
Restoring a backup:
docker compose exec -T db pg_restore --clean --if-exists -U antibot -d antibot < antibot-2026-11-10.dump
Download the dump from the bucket first, in Cloudflare's dashboard or with any S3 client pointed at R2.
What it gives up
- It is one machine. Its outage is the service's outage, for as long as it
takes to restore it: start a new server from the latest snapshot or backup.
Customers whose edges fail open (
missing: "allow", the default) keep their sites working through it. - There is no standby database, and nothing is replicated outside the machine except the daily backup. Up to a day of data can be lost.
- There is one region. Organizations cannot choose where their data lives until there is a second stack.
- It scales up, not out. A larger server (CAX31, CAX41) comes first, then the AWS stack.