Mobile Push (ntfy)
PizzaPi’s native Android app can receive background push notifications without Google/FCM by using a self-hosted ntfy instance. This is the same approach Home Assistant and Element use for Google-free push.
How it works
Section titled “How it works”- The PizzaPi server publishes a notification to a per-device topic on your
self-hosted ntfy instance with a single HTTP
POST. - The PizzaPi Android app holds a persistent subscribe stream to that topic in an Android foreground service, and posts a local notification when a message arrives. Native registrations receive this fan-out even when a web viewer is connected; the viewer only suppresses duplicate Web Push delivery.
- Because the connection is held by a foreground service, the app shows a persistent “PizzaPi — connected” notification. This is the unavoidable price of Google-free background push on Android.
-
Add the ntfy service to your compose stack.
The bundled
docker/compose.ymlalready includes anntfyservice (pinned tobinwiederhier/ntfy:v2.25.0, which includes the v2.22.0 security fix). It runsauth-default-access: deny-allwith a persistent cache so devices can catch up on missed messages. The server reaches ntfy internally ashttp://ntfy; no host port is published by default. -
Expose ntfy to devices behind your reverse proxy.
Devices must reach ntfy over the public internet to hold the subscribe stream. Add a dedicated subdomain (e.g.
push.example.com) that proxies tohttp://ntfy, forwardingConnection/Upgradeheaders for WebSocket/SSE. A minimal Caddy snippet:push.example.com {reverse_proxy http://ntfy}Or nginx (see the ntfy proxy docs for a complete example —
proxy_buffering offandConnection: upgradeare required for streaming). -
Provision a publish user + token.
Inside the ntfy container, create an admin user and a publish token:
Terminal window docker compose exec ntfy ntfy user add --role=admin pizzapi-publish# (set a strong password when prompted)docker compose exec ntfy ntfy token add pizzapi-publish# → prints tk_...With
auth-default-access: deny-all, the admin role grants read/write to all topics, so this token can publish to any per-device topic. (Phase 3 will tighten this with per-device read-only users.) -
Grant anonymous read-only access to device topics.
deny-allalso blocks anonymous reads, but in Phase 1 devices subscribe without credentials — the unguessable topic name is the secret. Grant anonymous (the built-ineveryoneuser) read-only access to thepizzapi-*topic namespace so the subscribe stream isn’t rejected with 403:Terminal window docker compose exec ntfy ntfy access everyone 'pizzapi-*' read-onlyThis grants read (subscribe) only — anonymous write stays denied by
deny-all, so the publish token from step 3 remains the only writer. Skip this and the Android foreground service gets a 403, treats it as a permanent error, and disables push (and the server prunes the registration on 403 or 404). (Phase 3 replaces this blanket grant with per-device read-only users.) -
Configure the server env vars.
Set these on the
serverservice (the bundled compose file already wiresPIZZAPI_NTFY_URL=http://ntfyinternally):PIZZAPI_NTFY_URL=http://ntfyPIZZAPI_NTFY_PUBLIC_URL=https://push.example.comPIZZAPI_NTFY_PUBLISH_TOKEN=tk_<your-publish-token>PIZZAPI_BASE_URL=https://relay.example.comIf
PIZZAPI_NTFY_URLis unset, the ntfy branch is a silent no-op and only Web Push runs.PIZZAPI_NTFY_PUBLIC_URLandPIZZAPI_NTFY_PUBLISH_TOKENare required for native push to actually function; without them, subscribe requests or publishes will fail.PIZZAPI_BASE_URLis the public URL of the PizzaPi web UI and is used for tap-through deep links in notifications. -
Register from the Android app.
On first launch (or when the user enables notifications), the app calls
POST /api/push/register-nativewith its API key (via thex-api-keyheader or a cookie session, minted during mobile-link enrollment). The server assigns an unguessable per-device topic (pizzapi-<48-hex-chars>) and returns{ ok, ntfyPublicUrl, topic, ntfyUser, ntfyPass }. Phase 1 leavesntfyUser/ntfyPassasnull; Phase 3 will populate them with per-device read-only ntfy credentials. The app’s foreground service subscribes to${ntfyPublicUrl}/${topic}/jsonand holds the stream.
Security model
Section titled “Security model”Phase 1 (current): the per-device topic is a 192-bit unguessable random
string. auth-default-access: deny-all denies everything by default; two
explicit grants open the minimum needed — the publish user can write any
topic, and anonymous (everyone) has read-only access to pizzapi-* so
devices can subscribe without credentials. Anonymous write stays denied, so the
publish token is the only writer. The topic name is the read-side security
boundary — anyone who learns a topic can read that user’s notifications, so
topics must never appear in logs, screenshots, or public URLs.
ntfy access tokens are not granular today (a token grants full account access), which is why per-device isolation uses per-device users with ACLs, not per-device tokens.
Limitations
Section titled “Limitations”- Foreground service notification is permanent. Users see “PizzaPi — connected” while push is active. This is inherent to Google-free background push on Android.
- Force-stop cannot be auto-recovered. If the user force-stops PizzaPi, the service won’t restart until the app is opened again. Same limitation applies to the ntfy app itself.
- OEM battery killers (Xiaomi/Oppo/Vivo/Samsung) may kill the foreground service despite the persistent notification. Users on those devices may need to exempt PizzaPi from battery optimization.
- Push is a hint, not the source of truth. On app foreground/reconnect, PizzaPi reconciles session state from the relay over the existing WebSocket — missed push notifications are not lost data.
- No iOS background push. By design — APNs would require Apple’s servers.
Troubleshooting
Section titled “Troubleshooting”- No notifications arrive. Check that
PIZZAPI_NTFY_URLis set on the server and thatcurl -H "Authorization: Bearer $PIZZAPI_NTFY_PUBLISH_TOKEN" -d "test" https://push.example.com/<topic>returns 200. Check the app’s foreground service notification is present (“PizzaPi — connected”). register-nativereturns 503. The server’sPIZZAPI_NTFY_URLis unset.- 429 from ntfy. You’re hitting the per-visitor rate limit. For a single
self-hosted user this shouldn’t trigger; if it does, tune
visitor-request-limit-burstin your ntfy config.