← Blog
/POST · ALFIE MILLS

Self-hosting iOS push notifications with Web Push and PWAs

For years the answer to "I want a custom notification on my iPhone" was either pay Pushover, run ntfy, or enrol in Apple's $99/yr developer program and ship something through App Store review. Since iOS 16.4, there's a fourth option that most people still don't know about: a Progressive Web App added to the home screen can receive real Web Push, routed through APNs by Apple, without the developer ever touching APNs or enrolling anything.

I went down this rabbit hole across two projects. The first, pwa-notify-test, was a throwaway harness to prove the platform actually works. The second, Notifirr, is the real thing built on top of what I learned.

Proving the platform first

The annoying part of iOS Web Push is the long list of preconditions, and the way it fails silently when one isn't met. You can only request notification permission from a PWA that has been added to the home screen and launched from the home screen icon, not from Safari, not from a tab. So before writing anything serious I built a debug harness whose entire job was to surface those conditions.

pwa-notify-test is a tiny Express server (web-push, express, qrcode-terminal) plus a service worker and a debug UI. The server generates VAPID keys on first run and persists them:

import webpush from 'web-push';

if (!state.vapid) {
  state.vapid = webpush.generateVAPIDKeys();
}
webpush.setVapidDetails('mailto:test@example.com', state.vapid.publicKey, state.vapid.privateKey);

Sending a push is then just:

await webpush.sendNotification(sub, payload, { TTL: 60 });

The client subscribes with that public VAPID key, posts the subscription back to the server, and the service worker handles the push event with showNotification.

The HTTPS problem, solved with Tailscale

iOS will not register a service worker or grant notification permission over plain HTTP. Locally that's a wall. The trick I leaned on (and still use) is tailscale serve:

tailscale serve --bg --https=443 http://localhost:3000

That puts your local app on https://<machine>.<tailnet>.ts.net with a real Let's Encrypt cert that iOS trusts, with zero cert wrangling. Open that URL in Safari on a phone on the same tailnet, Add to Home Screen, and you're in business. The harness prints a QR code of the URL so you don't have to type it.

The harness's most useful feature turned out to be the live log stream (server-sent events) that merges server, client, and service-worker logs into one view, plus status pills for is installed, is standalone, permission state, and subscription state. When something doesn't work on iOS, it's almost always one of those four being false, and being able to see which one saved me hours.

Notifirr: one icon per sender

The insight that made me build the real project: on iOS each installed PWA is fully independent on the home screen, its own icon, its own service worker, its own push subscription. So instead of one generic notifier app, I can install one PWA per logical sender, each with its own icon, name, and colour, all served from a single backend.

The result is notifirr -A sonarr "Done" "library scan complete" lighting up an orange Sonarr-skinned icon, while notifirr -A ci "Build #4421" "passed" lights up a different icon, even though it's all one container.

The stack is deliberately boring server-side:

  • Laravel 11 / PHP 8.3 behind FrankenPHP (Caddy + PHP in one binary)
  • Postgres as both database and queue, no Redis
  • Livewire + Blade for the admin UI
  • Sanctum API tokens, optionally scoped to a single app
  • minishlink/web-push for VAPID and payload encryption

Each "app" renders its own manifest.json, sw.js, and bootstrap script at /app/<slug>/, which is what gives every PWA its distinct identity. Sending happens through a SendWebPushJob on the queue; the dispatcher fans a single POST out to every device subscribed to the target app.

notifirr "Deploy finished" "production is live"
long-running-command && notifirr -A sonarr "Done" "library scan complete"

One thing I designed in from the start: the service layer sits behind contracts and ServiceProviders, with the Livewire admin and the HTTP API as two independent consumers of it. The push logic doesn't know or care whether it was triggered by a curl one-liner or a button in the UI.

Things worth knowing if you try this

  • Subscriptions expire. A push send can come back 404/410 when a subscription is dead. Drop those endpoints automatically or your sender will accumulate garbage.
  • Icons cache at install time. Changing a PWA's icon doesn't update the home screen one, iOS bakes it in when you Add to Home Screen, so a re-install is the only way to see a new icon.
  • AbortError on subscribe almost always means the app isn't actually installed yet, or you're below iOS 16.4.
  • Permission can only be requested from the installed PWA. If Notification.requestPermission() does nothing, you're in Safari, not the home-screen app.

The takeaway: real, custom, self-hosted push to an iPhone is genuinely possible now with nothing but a server you control and a PWA. No Apple developer account, no native app, no third-party service taking a cut.