May 7, 2026 Systems 6 min read Jordan Jones

Why I Rebuilt the Contact Form on a Raspberry Pi Instead of Paying HubSpot.

One Saturday. Four moving parts. Zero dollars a month. Here's the wiring diagram and the trade-offs I made on purpose.

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.db on 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.

QUESTION FROM THIS POST?

Want the FastAPI + n8n recipe?

I'll send you the docker-compose file, the FastAPI main.py, and the n8n workflow JSON. No charge. Just say what you want to wire up.

Quick replies typically within 24 hours.