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:migrateDocker, 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 --buildDocker, 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' \
formeasyWhen 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.