← All docs

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's CF-Connecting-IP header.
  • 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

  1. 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.json runs the stack too: every service is in the host's user namespace, which the host's network needs, and the front takes port 443 with setpriv rather than its file capability. Set APP_UID in .env (.env.example says to what), or the app cannot write its cache.

  2. 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.
  3. A Cloudflare API token with Zone → DNS → Edit on the zone, for the certificate's DNS-01 challenge.

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

  5. The settings. In infra/vps, copy .env.example to .env and app.env.example to app.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".

  6. Start it.

    cd infra/vps
    docker compose up -d --build
    

    The certificate comes first: the front waits for it. The app migrates the empty database when it first starts.

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

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.