Run it yourself.

Vercel is the easy path, not a requirement. The app is a standard Next.js server with two external services — Postgres and Clerk — so it runs on a platform, in a container, or on a box with Node, and it does not know the difference.

Vercel

The repository carries a deploy button. Two things it cannot do for you: run the migrations after the first deploy, and stop you from swapping Neon's two connection strings. The app queries through DATABASE_URL_POOLED and migrates through DATABASE_URL — the reverse of the names the Vercel integration uses.

# with .env.local holding the *direct* Neon URL as DATABASE_URL
pnpm db:migrate

Docker, on one machine

Compose brings up Postgres, applies the migrations, and starts the app. The migration runs as its own step, so a bad one fails before the app serves a single request. Data lives in a named volume.

cp .env.example .env     # the two Clerk keys and RESPONSE_HASH_SALT
docker compose up --build

Docker, with a hosted database

Use the image directly and point it at a database you already run. The Clerk publishable key is needed at build time, because NEXT_PUBLIC_* is inlined into the client bundle. Changing it means rebuilding, not restarting.

docker build -t formeasy .
docker run --rm -p 3000:3000 \
  -e DATABASE_URL_POOLED='postgres://…?sslmode=require' \
  -e RESPONSE_HASH_SALT='a-long-random-string' \
  formeasy

When it is not one machine.

Behind a reverse proxy
The per-address rate limit reads x-forwarded-for. A proxy that strips it silently disables that check — the honeypot and the size caps still work. Set it at the first hop and do not trust one a client supplied.
More than one instance
Build one image and deploy it everywhere, or set the same NEXT_SERVER_ACTIONS_ENCRYPTION_KEY on every build. Two instances built separately cannot read each other's Server Actions, which shows up as "Failed to find Server Action" for whoever lands on the wrong one.
Nothing else is shared
There is no cache to coordinate — no ISR, no use cache. The responses table is the only state there is, which is what makes a rolling deploy boring.

What is deliberately not configurable.

  • The rate limit is a throttle, not a quota — it counts rows per form, per hashed address, per hour, and is not atomic. Two simultaneous submissions can both fit in the last slot.
  • With no x-forwarded-for there is nothing to count per address, so the per-IP check is skipped rather than applied to everyone at once.
  • Respondent IPs are only ever stored hashed. RESPONSE_HASH_SALT is what keeps them unlinkable; set a real one.

The full reference — every environment variable, the migration step, backups, and the edge cases above — is in the repository. Read docs/self-hosting.md.