← All docs

Launching

The service launches on one server behind Cloudflare (decided 2026-11-10, for the bill): its compose file is infra/vps, and it takes the place of steps 1 and 2 below. AWS is where it grows to (deployment architecture): one region's infrastructure code and its runbook are drafted in infra/aws, written but not yet applied. The steps below are a launch on Vercel with Postgres as the database. Their settings, checks and order apply on any platform.

The steps that take this repository to a service the public can sign up to, in the order they depend on each other, with the check after each. The deployment architecture says why this platform and what it costs; the production checklist says what each setting is for and what goes wrong without it. This page is the order to do them in.

One region first. Add the others (step 10) once one serves the public well.

0. Accounts

  • Vercel Pro. Background work runs from Vercel Cron every five minutes (web/vercel.json); the Hobby plan runs a cron job at most once a day.
  • Postgres for the region's database (Aurora PostgreSQL, or any Postgres the functions can reach over TLS).
  • Stripe, if the service charges (Billing).
  • Resend, with a domain of yours to send from.
  • Sentry (a project) or a Slack incoming webhook, for errors.
  • An uptime monitor outside your infrastructure (Better Stack, Checkly, Instatus…), with a public status page.
  • The domain the app will answer on, e.g. app.example.com.

1. The database

Create the region's database in the cloud region of the Vercel function region you will choose in step 2: an identification waits on about ten statements in sequence, so each millisecond of round trip costs about ten on every identification (measured). Make it comparing text byte by byte — the app refuses any other:

CREATE DATABASE antibot TEMPLATE template0 ENCODING 'UTF8' LC_COLLATE 'C' LC_CTYPE 'C';

DATABASE_URL is then postgres://<user>:<password>@<host>:5432/antibot?sslmode=verify-full (for Aurora, the RDS certificate bundle in NODE_EXTRA_CA_CERTS). Nothing else: the first instance to start applies every migration.

2. The Vercel project

  • Import the repository; Root Directory web, framework Next.js. The build command is the package's build (pnpm build), which builds the browser agent first: every deploy gets a fresh agent build and salt, and the server reads them from .agent-builds.json, which the build writes and the deployment ships.

  • Function Region (Project Settings → Functions): the one next to the database.

  • To look visitor addresses up locally instead of sending them to a third party, have the build fetch DB-IP's free databases into intel/, which the routes that look addresses up ship (below):

    pnpm ip:databases intel && pnpm build
    

    and set ANTIBOT_IP_MMDB=intel/dbip-city-lite.mmdb and ANTIBOT_ASN_MMDB=intel/dbip-asn-lite.mmdb (credit "IP Geolocation by DB-IP" on the site). They are published monthly: a deploy a month keeps them current. The city database is 127 MB, shipped only with the routes that look addresses up (identification, the edge's assessments, the integrity key) and read into memory by an instance the first time it needs it: give those functions the memory for it, or use IPINFO_TOKEN instead. The public blocklists need no build: on a long-running server, ANTIBOT_BLOCKLISTS=fetch has the service fetch them itself — one instance an hour tries, IPsum every six hours and Spamhaus once a day, kept in the database and written from there to every instance. Otherwise they work as the databases do (pnpm blocklists intel/blocklists, ANTIBOT_BLOCKLISTS_DIR=intel/blocklists), but Spamhaus asks that DROP be fetched no more than once a day, and a build fetches it every time: add them to the build only if you deploy at most daily, or leave them off — Tor exits and your threat feed still apply.

3. The settings

Write them in a file first and let the doctor read it before Vercel does:

cd web
pnpm doctor --env-file .env.production --serverless

It names every setting that is missing or wrong and what that costs, checks the database answers (and how quickly — from your machine, so read the time as a lower bound), and exits 1 while anything is unsafe. The ones a first launch needs:

Setting Value
DATABASE_URL From step 1
ANTIBOT_PUBLIC_URL https://app.example.com
ANTIBOT_CLIENT_IP_HEADER x-vercel-forwarded-for (detected on Vercel anyway); cf-connecting-ip if Cloudflare sits in front
CRON_SECRET A long random value (openssl rand -hex 32). Vercel Cron sends it to /api/cron itself
IP_PSEUDONYM_KEY Another long random value, kept as long as the data is
CLOUDFLARE_EMAIL_TOKEN and CLOUDFLARE_EMAIL_ACCOUNT_ID, or RESEND_API_KEY; ANTIBOT_MAIL_FROM Mail through Cloudflare's Email Sending (an API token allowed to send, and the account the domain is onboarded in; it needs the Workers Paid plan) or else Resend, from LayerSift <[email protected]> on a domain verified with it
STRIPE_* Billing: the product, the prices, the webhook, the portal
ANTIBOT_SENTRY_DSN and/or ANTIBOT_ERROR_WEBHOOK_URL Where errors are told
ANTIBOT_METRICS_TOKEN If something scrapes /api/metrics
ANTIBOT_SERVICE_IPS The addresses customers point their own subdomains at

web/.env.example lists the rest. Then copy them into the project (Settings → Environment Variables, Production) — vercel env pull brings them back into a file whenever you want the doctor to look again.

4. Deploy, and look

pnpm smoke --url https://app.example.com

From outside, as a visitor and a stranger reach it: liveness and readiness (and how long the database took), the agent served as a script, sign-in not frameable and kept on https (HSTS), the server not naming itself, the background jobs and the metrics closed to anyone. With --public-key and --origin (after step 5) it also checks that the origin may identify — the browser's preflight, an integrity key issued — and that another origin may not.

The function logs at start-up name any setting still missing or wrong (the doctor's findings, from the deployment's own environment). Then the three checks at the end of the production checklist: the dashboard cannot be framed, the log names the address header, and a forged x-forwarded-for does not become a visitor's address.

5. Your own organizations

Sign up — the first account is an ordinary one; production never seeds the demo. Then two organizations of your own:

  • Your own traffic. Its production environment's allowed origins include the app's origin; create a secret key and a public key and set ANTIBOT_SELF_PROTECT_SECRET_KEY and ANTIBOT_SELF_PROTECT_PUBLIC_KEY. Your sign-up and sign-in are then assessed by the service, as a customer's are (why).
  • The demo. Every visitor to the marketing pages, bots included, is identified into it: set its event retention to seven days, allow the marketing origin, and set its public key as NEXT_PUBLIC_DEMO_KEY. A NEXT_PUBLIC_ setting is built into the pages: redeploy after setting it.

6. Being told

  • The uptime monitor on https://app.example.com/api/ready, every minute, and its status page's address as ANTIBOT_STATUS_URL (the footer links to it).
  • Errors are told to Sentry and/or Slack from the first one. The doctor checks the DSN's shape, not that Sentry accepts it: send a test event from Sentry's project settings, or post to the Slack webhook once with curl, to know the destination is the right one.
  • What the app goes on answering past is in its log alone, each kind said at most once a minute: the database stands in for it (the short-lived store out of reach), traffic alerts could not be evaluated, an audit entry (one not written), [retention] sweep failed, not reported this time (usage not reported to billing), could not be read, so (an address database or the blocklists), an error report could not be sent, [cron] (a background job that failed, by name — here, where the cron route runs them, the retention sweep and the usage report among them), and, when an instance starts, [antibot] production checklist: and has no agent build of its own. Alert on them through a log drain; on AWS, infra/aws/alarms.tf does.

Fill every [BRACKETED] placeholder in web/content/legal/*.md (company name, address, contacts, jurisdiction, subprocessors — the hosting and database providers, Stripe, Resend and whatever monitoring you chose), have counsel review the four documents, and set ANTIBOT_LEGAL_REVIEWED=1. Until then each page says it is a draft not in force, and pnpm doctor lists what is still bracketed.

8. Before opening the doors

pnpm doctor --env-file .env.production --serverless   # "Nothing in the way."
pnpm smoke --url https://app.example.com --public-key pk_live_… --origin https://www.example.com

and sign up once more from a clean browser, as a customer would: confirm the address by its email, start the trial, create a key, identify from the getting-started snippet, open the event, subscribe with a test card if Stripe is in test mode (then switch it to live keys and run the doctor again: it says so while it is not).

9. Keeping it current

  • Deploys roll forward. The database migrates on the first instance of a new deploy, and a migration is not undone by rolling the code back: before an instant rollback, run pnpm doctor against production from a checkout of the release you are going back to — it says when the database was migrated by a newer one.
  • The release before runs against the new schema. While a deploy rolls, the instances already serving go on doing so on the database the new one migrated. So a migration adds — a table, a column that may be null or has a default — and what would break the release before (a drop, a rename, a type changed, NOT NULL set) waits for the release after the one that stopped using it. An index on a table in use is built by hand first, CREATE INDEX CONCURRENTLY, and its migration says IF NOT EXISTS: a migration runs in a transaction, where CONCURRENTLY cannot, and otherwise the table's writes wait for the build. pnpm test:migrations refuses each of these in a new migration until its file says why it is safe, on a line -- rolling deploy:. A migration waits at most two seconds for a table's lock, a few times over, so it never holds that table's queries behind a long one (ANTIBOT_MIGRATION_LOCK_TIMEOUT_MS).
  • Monthly: a deploy, for the IP databases (step 2).
  • Agent builds: each deploy is a new build; the previous one stays accepted, and any older one for two hours after the build that replaced it — the hour a browser keeps the agent, and an hour of its page — so even deploys in quick succession leave a page loaded before them its session.

10. More regions

Each region is the whole of steps 1–8 again: its own database, its own Vercel project in a function region inside it, its own public URL. Then, in every region's settings: ANTIBOT_REGION (us, eu, ap), the same ANTIBOT_REGIONS (every region's URLs), and the same directory — ANTIBOT_DIRECTORY_URL (a Postgres every region reaches) and an ANTIBOT_DIRECTORY_KEY of at least 32 characters that is the same everywhere (the doctor checks it is the key the directory was made with). See Deployment architecture.

What does not scale yet

Found by planning every statement the app runs (scripts/plan-audit.mjs), and left as they are for launch:

  • Deleting a large environment, or organization, goes in batches of 5,000 rows, so identifications are never held behind one long statement; one so large that the request outlasts the platform's time limit is still there, part-emptied, and deleting it again finishes the job (its own row goes last). Deleting an account that owns organizations is one transaction, which holds its organizations throughout: empty a large organization first (set its retention to one day, which is the organization's for all its environments, and let the nightly sweep remove its identifications), then delete the account.