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'sbuild(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 buildand set
ANTIBOT_IP_MMDB=intel/dbip-city-lite.mmdbandANTIBOT_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 useIPINFO_TOKENinstead. The public blocklists need no build: on a long-running server,ANTIBOT_BLOCKLISTS=fetchhas 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 |
RESEND_API_KEY, ANTIBOT_MAIL_FROM |
LayerSift <[email protected]> on a domain verified in Resend |
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_KEYandANTIBOT_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. ANEXT_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 asANTIBOT_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:andhas no agent build of its own. Alert on them through a log drain; on AWS,infra/aws/alarms.tfdoes.
7. Legal
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 doctoragainst 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 NULLset) 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 saysIF NOT EXISTS: a migration runs in a transaction, whereCONCURRENTLYcannot, and otherwise the table's writes wait for the build.pnpm test:migrationsrefuses 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.