• 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-login only 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
UserRoleCan doLives in
adminadminRead everything, manage usersPhone app login
publisheruser, write-only on one topicPublish to that topic onlyToken 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:

CheckCommandExpected
Up through the tunnelcurl -s https://ntfy.example.com/v1/health{"healthy":true}
Anonymous publish refusedcurl -d test https://ntfy.example.com/<topic>403
Anonymous read refusedcurl "https://ntfy.example.com/<topic>/json?poll=1"403
Random topic refused toocurl -d test https://ntfy.example.com/anything403

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_URL secret, 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.