- Aug 3, 2026
- 7 min read
Contact Forms with Cloudflare Workers KV and Self-Hosted ntfy Alerts
My sites are small and every enquiry matters. I wanted three things: the message must never be lost, I want to know within seconds, and I don’t want a form SaaS script on every page. The result is a Cloudflare Worker that writes each submission to Workers KV and then pings my phone through a self-hosted ntfy server.
It replaced a third-party form/survey tool on one site and a form-to-email service on another. The whole flow:
visitor submits form
→ Worker POST /api/forms/submit
1. validate (honeypot, kind, email, consent)
2. write the lead to KV ← the record of truth
3. answer 202 to the visitor
4. in the background: POST an alert to ntfy
→ ntfy (homelab) → phone notification
Why KV for a lead inbox
| Workers KV | |
|---|---|
| Servers to run | None |
| Cost at this scale | Free tier (1,000 writes per day) |
| Where the data lives | My own Cloudflare account |
| Reading it | One CLI command (wrangler kv key list) |
| Lock-in | Low: JSON values, exportable |
| What it lacks | No UI, no statuses, no notifications |
I compared it with a hosted form tool, PocketBase, Supabase and a FastAPI + SQLite service on the homelab. For “a few messages a week, one person” KV won on zero operations. The missing pieces, notifications and an inbox view, are exactly what the rest of this post adds.
One rule from that comparison stuck: never let the browser post straight to the homelab. A home power or ISP outage would silently lose leads. Keep a durable capture at the edge, and let everything else be best effort.
Store first, alert second
The order of operations in the handler is the whole design:
const key = leadKey(lead, crypto.randomUUID());
try {
await env.LEADS.put(key, JSON.stringify(lead), {
...(ttl ? { expirationTtl: ttl } : {}),
metadata: { site: lead.site, profile: lead.profile, kind: lead.kind, email: lead.email, project: lead.project ?? "" },
});
} catch (error) {
console.error("kv put failed", error);
return json({ error: "storage_failed" }, 502, cors);
}
// Alert only after the lead is stored; never block or fail the response on it.
ctx.waitUntil(notify(env, lead).catch((error) => console.error("ntfy", error)));
return json({ ok: true }, 202, cors);
- If KV fails, the visitor gets an error (502) and the form shows my email address as a fallback. A failed save is never reported as success.
- If ntfy is slow or down, the visitor doesn’t notice.
waitUntillets the Worker finish the alert after the response is sent, and the.catchkeeps a failed alert from becoming an unhandled rejection.
Both cases have a test: KV failure returns 502 and sends no alert; an unreachable ntfy still returns 202 with the lead stored. Self-hosted alerts mean no alerts while the homelab is down, which is acceptable precisely because the lead is already safe.
The key and the metadata
Each lead is one JSON value under a descriptive key:
<site>:<kind>:<ISO timestamp>:<uuid>
fossengineer.com:enquiry:2026-10-04T19:29:51.123Z:4f1c…
Sorting by key sorts by site, then kind, then time. Listing a prefix like
fossengineer.com:newsletter: gives one site’s subscribers, and the UUID
keeps two submissions in the same millisecond apart.
KV metadata is the underrated part. list returns each key’s metadata
(up to 1 KB) without reading the values, so I store the fields I need for a
table view there: site, profile, kind, email, project. A listing is one API
call no matter how many leads exist.
When I moved from a per-site function to a central Worker, the key format
gained the site prefix. Old keys (<kind>:<iso>:<uuid>) stayed, and every
reader understands both: if the first segment contains a dot, it’s a site.
Retention is set at write time as a TTL (expirationTtl), so enquiries
disappear after the period my privacy policy promises without a cleanup job.
A small reader instead of a dashboard
KV has no admin UI, and I don’t need one. A Python script wraps
wrangler kv key list and prints a table through make targets:
make leads # time, site, kind, status, email, project
make lead KEY=<key> # one lead in full
make lead-delete KEY=<key>
make leads-test # POST a test enquiry to the live endpoint
make leads-clean-tests # delete test leads (@example.com)
make leads-test plus make leads-clean-tests is my end-to-end check after
every change: real form endpoint, real KV, real phone notification, then
clean up.
The alert: enough to triage, nothing personal
Notifications travel through more systems than the lead itself: the ntfy server, the phone’s push service, the lock screen. So the alert carries the site, the kind, the project, the email domain and the country. Never the name, the full address or the message.
export function alertFor(lead: Lead) {
const domain = lead.email.split("@")[1] ?? "unknown";
const headers = {
// Header values must stay ASCII (an em dash triggers a Workers warning).
Title: `New ${lead.kind} - ${lead.site}`,
Tags: lead.kind === "enquiry" ? "incoming_envelope" : lead.kind === "ebook" ? "books" : "newspaper",
Priority: lead.kind === "enquiry" ? "high" : "default",
};
const parts = [lead.project, `from @${domain}`, lead.country].filter(Boolean);
return { headers, body: `${parts.join(" · ")}\nDetails: make leads` };
}
On the phone that reads:
New enquiry - multibodysystemsdynamics.com
Solver validation · from @acme.com · PL
Page: /contact/
Details: make leads
@acme.com versus @gmail.com is all I need to decide whether to look now.
The body also names the page that converted (path only, no query string,
since UTMs can carry tags), so I can see which posts actually bring
subscribers.
Enquiries get high priority; ebook and newsletter sign-ups use the default.
The ASCII header gotcha
My first title was New enquiry — multibodysystemsdynamics.com with an em
dash. The alert arrived, but the Workers log showed a warning that the
Title header contains a non-ASCII character. HTTP header values are
expected to be ASCII; how a non-ASCII value is handled depends on every hop
in between. The fix was a plain hyphen in headers, while the body (where the
· lives) can be any Unicode. There is now a test asserting every header
value matches /^[\x20-\x7e]*$/.
Publishing to ntfy securely
The Worker publishes with a bearer token over HTTPS:
const response = await fetch(env.NTFY_URL, { method: "POST", headers, body });
// NTFY_URL = https://ntfy.example.com/<topic>, NTFY_TOKEN sent as Authorization: Bearer
Both values are Worker secrets, set with wrangler secret put so they never
appear in git or a Makefile. If NTFY_URL is unset, notify() does nothing,
which keeps local development quiet.
The token belongs to a machine user that can only publish to one topic: it can’t read alerts or touch anything else. The server itself denies everything by default, and I verified that from outside (anonymous publish, anonymous read and random topics all return 403). That setup, behind a Cloudflare Tunnel, deserves its own post.
Gotchas I hit
| Symptom | Cause | Fix |
|---|---|---|
| Secrets set, no alerts (Pages Function era) | Pages applies secrets only to new deployments | Redeploy after pages secret put |
| Workers log: header contains non-ASCII | Em dash in Title | ASCII in headers, Unicode in the body |
| Laptop ntfy unreachable from Cloudflare | Cloudflare can’t reach a laptop | Public hostname + tunnel on an always-on box |
wrangler pages deployment tail errors in scripts | It can’t pick a deployment non-interactively | Pass the deployment ID |
What I’d do again
- Write to durable storage before anything else, and never report a failed save as success.
- Put side effects in
waitUntilso a slow homelab can’t slow the visitor. - Use KV metadata for listing and a descriptive key that sorts by time.
- Keep personal data out of notifications. The email domain is enough to triage.
- Least privilege for the publishing token, and prove deny-all from outside.
- ASCII in HTTP headers, tested.
This started as a function inside one site. Once three sites needed the same thing, I moved it into one Worker that every site reaches on its own domain. Next: one forms Worker for every site, with same-origin routes over Cloudflare Pages.