One server behind Cloudflare
The launch shape, decided 2026-11-10 to keep the bill small: one server that holds the app and the database, and Cloudflare for everything in front of it. Since 2026-11-13 visitors reach the server through a Cloudflare Tunnel, so the server opens no port to the internet. 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, through the tunnel | 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 |
Cloudflare, proxied, through the tunnel | No open port. The cost is the visitor's handshake (below). |
The tunnel (cloudflared) |
The server, connecting out to Cloudflare | Carries both names to the front. Nothing on the server listens beyond loopback. |
| The TLS front (front/, in Rust) | The server, 127.0.0.1:443 |
Ends TLS for both names, takes the visitor's address from Cloudflare, 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.
What the tunnel costs: the visitor's handshake. Behind Cloudflare's proxy,
and so through the tunnel, 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). Through the tunnel, identifications on
api.example.com have no handshake to read: the network signal says the
transport was not available, and those checks stay silent.
Customers who run the CDN proxy send their visitors' traffic through their own CDN, whose evidence (CloudFront's JA4, for one) the app reads as before.
Answering directly instead. To read every visitor's handshake on
api.example.com, the front answers the internet itself:
- Open 443/tcp to all in the provider's firewall.
- In
.env, leaveCOMPOSE_PROFILES=tunnelout and setFRONT_HOST="::". - Point
appat the server with proxied A and AAAA records, andapiwith DNS-only ones.
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 is what stands in front of it.
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. The compose file counts loopback among them (ANTIBOT_FRONT_CLOUDFLARE_RANGES): the tunnel's connections come fromcloudflaredon the same machine, and its handshake is not the visitor's either. - 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 127.0.0.1 behind the tunnel (FRONT_HOST in .env), or :: to answer the internet on IPv4 and IPv6. |
TUNNEL_TOKEN, COMPOSE_PROFILES=tunnel |
cloudflared, compose |
The tunnel's token (cloudflared tunnel token <name>), and the profile that starts it. |
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. Behind the tunnel, open no port in the provider's firewall but 22/tcp to your own address (or none, over a VPN). Everything 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.The tunnel. With
cloudflaredon the server:cloudflared tunnel login # authorize the zone, in the browser cloudflared tunnel create layersift cloudflared tunnel token layersift # into TUNNEL_TOKEN in .envThen delete
~/.cloudflared: the token is all the server keeps.In the zone's DNS,
appandapiare each a proxied CNAME to<tunnel id>.cfargotunnel.com. The app's name may be the bare domain (APP_HOST=example.com, as layersift.com runs): a CNAME at the apex works on Cloudflare, and a redirect rule sendswwwto it.cloudflaredchecks the front's certificate against the API's name, so the front still needs a real one (step 3).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 address should be the browser's. Behind the tunnel its network signal says the transport was not available. Answering directly, it says it was, and names 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.
- Behind the tunnel, the hosted API reads no visitor's handshake (above).
- Customers' own subdomains are not served. The front's certificate names
only the two names, and behind the tunnel the server's addresses answer
nothing, so
ANTIBOT_SERVICE_IPSpoints customers at nothing that works. - It scales up, not out. A larger server (CAX31, CAX41) comes first, then the AWS stack.