The form on every page of this site used to be a lie. You'd hit submit
and the JS would do e.preventDefault() then swap the button
text to "Thanks." That was the whole pipeline. The lead went nowhere.
I wrote it that way in April because I wanted the page to ship before I
picked a backend.
Six weeks later I had three real submissions I'd never seen. Time to wire it up. The obvious move was HubSpot Free or Zoho Forms — point and click, done in an hour. I almost did it. Then I looked at what I already owned: a Raspberry Pi running n8n, Nextcloud, and a static site container behind a Cloudflare tunnel. Adding a form backend was four small files and an afternoon.
The Wiring Diagram
The pipeline has four moving parts, in order:
- FastAPI service — accepts the POST, validates with a Pydantic model, writes to SQLite first (so we never lose a lead), then fires the notification.
- SQLite file — single
leads.dbon the Pi's SSD. Schema is one table. Backup is "copy the file." - n8n webhook — receives the notification call, formats an HTML email, sends via the Gmail node I already had wired for other workflows.
- Cloudflare tunnel — already existed. Just added one nginx route for
/api/*.
The "Save Before Notify" Choice
The order matters. The API writes the row to SQLite before it fires the n8n webhook. If n8n is down or Gmail is rate-limiting, the lead is still saved. Worst case I find out 24 hours later when I check the DB. I do not want a failure path that drops a lead because the notification stack hiccuped.
What This Cost
The Pi is sunk cost — it runs my Nextcloud, n8n, three static sites, and now this. The container draws maybe 0.3W extra. Cloudflare tunnel is free. SQLite is free. FastAPI is free. n8n self-hosted is free. The Gmail OAuth credential I already owned. Total marginal cost: $0/month.
HubSpot Free would have been free too — for now. But:
- The lead data would live on someone else's server.
- I'd inherit whatever schema and field limits HubSpot decided.
- The free tier disappears the second a real funnel needs five rules.
- Migration off would mean rewriting the form, the receiver, and the routing — at the worst possible time (when leads start mattering).
Two hours of work today bought me 100% control of every byte that comes through that form. That's the trade I wanted.
The Cache-Bust Gotcha
One thing burned 20 minutes: Cloudflare was serving the old static
script.js with the "Thanks." button-swap hack, while the
new submit handler sat unused. curl -I showed
cf-cache-status: HIT for two hours. Fix was a query-string
cache-bust: script.js?v=20260507a on every page that loads
it. Cloudflare treats the new URL as a new asset and pulls fresh from
origin.
"If a static asset behavior won't change after a redeploy, the cache is lying to you. Bust the query string." — A note I keep pinned in the project README
What I'd Do Differently
Almost nothing. The one piece I'd add up front is a rate limit on the
POST endpoint — right now an attacker could mash submit and fill
leads.db with junk. I'll add nginx limit_req
this week. Cost: a config line. Not worth blocking the launch over.
If you're an owner-operator looking at HubSpot or Zapier for a contact form and you already have any kind of server — a Pi, a $5 VPS, a NAS, anything — try the four-piece pattern first. You'll get a system you own, you can rewrite, and you can move. The complexity ceiling is higher than the no-code alternatives. The floor isn't.