- Aug 6, 2026
- 5 min read
Self-Hosting ntfy Behind a Cloudflare Tunnel, Locked Down
Every lead on my sites pings my Android phone within seconds. The notifications come from my own ntfy server on a homelab, published through a Cloudflare Tunnel, and the forms Worker posts to it with a token (how the forms side works).
A public notification server is only fine if nobody else can use it. This is how I set it up, the checks I run from outside, and the parts I deliberately left out.
Start on the laptop, then move
I tried the alert format on a laptop first:
docker run -p 8099:80 binwiederhier/ntfy serve
Publishing with Title, Priority, Tags, Click and Markdown headers
and reading back with /<topic>/json?poll=1 let me design the message before
anything was public.
That laptop instance had no authentication at all, which was fine on localhost — and it could never receive alerts from the site anyway, because Cloudflare can’t reach a laptop. The real server needed a stable home and a public hostname: the homelab, behind a tunnel, with auth on.
Configuration: deny-all first
The setting that matters most is the default access:
# server.yml
base-url: "https://ntfy.example.com"
auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all" # nothing readable or writable without auth
cache-file: "/var/cache/ntfy/cache.db"
With deny-all, an anonymous visitor can neither publish to nor read any
topic. Every permission is then granted explicitly to a user.
Options I left out on purpose:
enable-loginonly adds a login to ntfy’s browser UI. I read alerts in the Android app, which authenticates on its own, so the web UI login isn’t needed. Without it the web UI simply shows no login — that’s expected, not a security hole, because the server still refuses anonymous access.upstream-base-url: "https://ntfy.sh"is what iOS needs for instant pushes from a self-hosted server. Android doesn’t, so I’ll add it only if an iPhone ever joins.
Two users, two roles
docker exec -it ntfy ntfy user add --role=admin <your-user> # phone app login
docker exec -it ntfy ntfy user add <publisher> # machine user for the sites
docker exec -it ntfy ntfy access <publisher> <topic> write-only
docker exec -it ntfy ntfy token add <publisher> # → tk_… for the Worker
| User | Role | Can do | Lives in |
|---|---|---|---|
| admin | admin | Read everything, manage users | Phone app login |
| publisher | user, write-only on one topic | Publish to that topic only | Token stored as a Worker secret |
The sites never hold the admin password. If the publisher token leaked, the
worst case is spam on one topic: it can’t read alerts or touch other topics.
And the token goes into the Worker with wrangler secret put, so it never
appears in git, a Makefile or a chat log.
The tunnel
In Cloudflare Zero Trust → Networks → Tunnels, I created a tunnel on the
homelab and a public hostname pointing at the ntfy container
(http://ntfy:80 or http://localhost:<port>). Cloudflare creates the DNS
record itself. No ports opened on the router.
One thing not to do: put Cloudflare Access in front of ntfy. Access means a browser login page, and neither the phone app nor a Worker can pass it. ntfy’s own authentication is the protection here.
Prove it from outside
After any config change I rerun these from a machine that isn’t the homelab:
| Check | Command | Expected |
|---|---|---|
| Up through the tunnel | curl -s https://ntfy.example.com/v1/health | {"healthy":true} |
| Anonymous publish refused | curl -d test https://ntfy.example.com/<topic> | 403 |
| Anonymous read refused | curl "https://ntfy.example.com/<topic>/json?poll=1" | 403 |
| Random topic refused too | curl -d test https://ntfy.example.com/anything | 403 |
The last one matters: it proves the server is deny-all, not just that one topic is reserved.
The phone
In the ntfy Android app: add the server URL, log in as the admin user, subscribe to the topic. That’s all.
What the alert contains
The Worker sends site, form kind, project and the email domain — never the name, full address or message:
New enquiry - multibodysystemsdynamics.com
Solver validation · from @example.com · PL
Details: make leads
Notifications travel through more systems than you think (ntfy’s cache, the phone’s push service, the lock screen). The domain is enough to triage; the full lead stays in KV.
One gotcha: HTTP header values must be ASCII. An em dash in the Title header
produced a warning in the Worker logs, so the title uses a plain hyphen. The
body can contain Unicode.
Trade-offs I accepted
- No alerts while the homelab is down. The lead is stored in KV before the alert is attempted, and the alert runs in the background, so a dead homelab never fails or slows a submission. I’d just learn about the lead later.
- Hosted ntfy.sh is a drop-in fallback: if alerts ever matter more, it’s
a change of the
NTFY_URLsecret, not of code.
Rotating the publisher token
docker exec -it ntfy ntfy token add <publisher> # new tk_…
npx wrangler secret put NTFY_TOKEN # paste it; Worker secrets apply immediately
# send a test lead, confirm the phone pings, then:
docker exec -it ntfy ntfy token remove <publisher> <old tk_…>
If you use Pages Functions instead of a Worker, remember that Pages secrets only apply to new deployments — I lost some time wondering why alerts didn’t arrive until I redeployed.
Takeaways
auth-default-access: deny-all, then grant permissions per user.- An admin user for humans, a write-only token for machines.
- Verify from outside with anonymous publish and read attempts, including a random topic.
- No Cloudflare Access in front of ntfy; its own auth does the job.
- Keep personal data out of notifications, and store before you alert.
Next: double opt-in on Cloudflare Workers with signed links.