Offer a push service, so Android does not have to mean Google

Web Push assumes a push service: something that owns the endpoint URL, holds
the connection to the device and receives our encrypted POST. Browsers ship
one. A native Android app does not, and Android's own is FCM, which speaks its
own protocol and cannot be reached by WebPushSender at all.

That left a self-hoster with a gap this repository was well placed to close and
did not: to get push without Firebase they had to find and run a push service
themselves, having already been asked to self-host their mail.

So ntfy is available here, behind a `push` profile and off by default:

  NTFY_BASE_URL=https://push.example.com docker compose --profile push up -d ntfy

It adds no application code. ntfy is simply a push service speaking the
protocol WebPushSender already emits -- RFC 8030 transport, RFC 8291 payload
encryption, RFC 8292 VAPID -- so a UnifiedPush distributor on the phone can
receive what this server already sends today. Nobody who does not want push
pays for a container they never start.

Two settings are worth reading before exposing it. NTFY_BASE_URL is the URL
*phones* use, so it must be reachable from outside this machine, and it is
baked into every endpoint issued -- changing it later forces every device to
re-register. And access is open by default, which is a considered choice rather
than an oversight: UnifiedPush topics are random and unguessable, and payloads
are encrypted to the device's own key before they arrive, so this service
cannot read mail and someone who guessed a topic could only send noise the app
discards. The comment says how to lock it down for an internet-facing
deployment.

Verified end to end against the isolated Android test stack: the app registers
with the distributor, ntfy issues an endpoint, the app registers it through
PushSubscription/set, this server POSTs its encrypted PushVerification there,
the connector decrypts it and echoes the code back. PushSubscription/get then
returns the subscription with no verificationCode, which is what live means.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-01 00:09:37 +02:00
1 parent 6edf33fed9
commit 37cf7ab254
2 files changed
+86

No files matched your search

+55
View File
@@ -200,6 +200,60 @@ services:
- mercure_config:/config
- app_secrets:/run/plmail:ro
# Push for Android, without Google.
#
# Optional and off by default: `docker compose --profile push up -d`. Nobody
# who does not want push pays for a container they never use.
#
# This needs no plMail code at all. The app already speaks Web Push — RFC 8030
# transport, RFC 8291 payload encryption, RFC 8292 VAPID signing — and ntfy is
# a push service that speaks exactly that. What it adds is the missing half on
# Android: RFC 8030 requires a *push service* that owns the endpoint URL and
# holds the connection to the device, browsers ship one and native apps do
# not. A UnifiedPush distributor on the phone talks to this.
#
# The alternative for an Android user is Firebase, which means Google sees
# that a message arrived and when. Running this instead keeps that on your own
# hardware, which is the reason to self-host mail in the first place.
ntfy:
image: binwiederhier/ntfy
profiles: [push]
restart: unless-stopped
command: serve
environment:
# The URL *phones* will use, so it has to be reachable from outside this
# machine — a LAN address, a Tailscale name, or a public hostname. It is
# baked into every endpoint handed out, so changing it later invalidates
# every existing subscription and every device has to re-register.
NTFY_BASE_URL: ${NTFY_BASE_URL:-http://localhost:8090}
NTFY_LISTEN_HTTP: ':80'
NTFY_CACHE_FILE: /var/lib/ntfy/cache.db
NTFY_AUTH_FILE: /var/lib/ntfy/auth.db
# Open by default, and that is a considered choice rather than laziness.
# UnifiedPush topics are random and unguessable, and the payload is
# encrypted to the device's own key before it ever reaches here — so this
# service cannot read your mail, and someone who guessed a topic could
# only send noise the app fails to decrypt and discards.
#
# To lock it down anyway, set this to deny-all and create a user:
# docker compose exec ntfy ntfy user add --role=admin plmail
# then give the phone that login in the ntfy app. It is the right move if
# this is exposed to the internet rather than reached over a VPN.
NTFY_AUTH_DEFAULT_ACCESS: ${NTFY_AUTH_DEFAULT_ACCESS:-read-write}
# A personal server has a handful of devices; the defaults are sized for a
# public instance and would let one client exhaust it.
NTFY_VISITOR_REQUEST_LIMIT_BURST: '60'
NTFY_VISITOR_REQUEST_LIMIT_REPLENISH: '5s'
ports:
- "${NTFY_PORT:-8090}:80"
volumes:
- ntfy_data:/var/lib/ntfy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/v1/health"]
timeout: 5s
retries: 5
start_period: 20s
volumes:
app_secrets:
caddy_data:
@@ -207,3 +261,4 @@ volumes:
database_data:
mercure_data:
mercure_config:
ntfy_data:
+31
View File
@@ -449,6 +449,37 @@ you can create a Web Push subscription; RFC 8620 defines no standard place for i
Note `capabilities` advertises the push URN but the *supported* `using` list is Core, Mail and
Submission only. Do not put the push URN in `using`.
#### Push on Android, without Google
Web Push assumes a **push service**: something that owns the endpoint URL, holds the connection to
the device and receives the server's encrypted POST. Browsers ship one. A native Android app does
not, and Android's own service is FCM, which speaks its own protocol — `WebPushSender` cannot POST
to it.
So an Android client has three options, and only the first needs nothing from this server:
1. **UnifiedPush.** The user installs a *distributor* app; it supplies an RFC 8030 endpoint and
decrypts the RFC 8291 `aes128gcm` payload this server already sends. No server change at all.
2. **Firebase.** Nothing for the user to install, and what most Android users expect — but it needs
a Firebase project and an FCM sender here, and Google then learns that a message arrived and
when.
3. **An embedded distributor**, where the app holds the socket itself. Costs a foreground service
and a permanent notification, per app.
For (1) this repository can supply the push service too, so a self-hoster does not have to find one:
```bash
NTFY_BASE_URL=https://push.example.com docker compose --profile push up -d ntfy
```
It is off by default and adds no plMail code — ntfy is simply a push service that speaks the
protocol `WebPushSender` already emits. `NTFY_BASE_URL` must be reachable **from the phone**, and is
baked into every endpoint issued, so changing it later forces every device to re-register.
Payloads are encrypted to the device's own key before they reach it, so the push service cannot read
mail whichever one you use. It does learn *when* mail arrives, which is the argument for running
your own rather than a public one.
### The API endpoint
```http