[{"data":1,"prerenderedAt":776},["ShallowReactive",2],{"blog-self-hosted-ios-web-push":3,"blog-post-nav":738},{"_path":4,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":8,"description":9,"date":10,"body":11,"_type":732,"_id":733,"_source":734,"_file":735,"_stem":736,"_extension":737},"/blog/self-hosted-ios-web-push","blog",false,"","Self-hosting iOS push notifications with Web Push and PWAs","How I built self-hosted push notifications to my iPhone using Web Push, VAPID, and one PWA per sender, no Apple developer account required.","2026-03-25",{"type":12,"children":13,"toc":725},"root",[14,22,42,49,62,96,242,247,287,308,315,328,365,378,409,415,427,448,453,515,551,615,635,641,714,719],{"type":15,"tag":16,"props":17,"children":18},"element","p",{},[19],{"type":20,"value":21},"text","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.",{"type":15,"tag":16,"props":23,"children":24},{},[25,27,33,35,40],{"type":20,"value":26},"I went down this rabbit hole across two projects. The first, ",{"type":15,"tag":28,"props":29,"children":30},"strong",{},[31],{"type":20,"value":32},"pwa-notify-test",{"type":20,"value":34},", was a throwaway harness to prove the platform actually works. The second, ",{"type":15,"tag":28,"props":36,"children":37},{},[38],{"type":20,"value":39},"Notifirr",{"type":20,"value":41},", is the real thing built on top of what I learned.",{"type":15,"tag":43,"props":44,"children":46},"h2",{"id":45},"proving-the-platform-first",[47],{"type":20,"value":48},"Proving the platform first",{"type":15,"tag":16,"props":50,"children":51},{},[52,54,60],{"type":20,"value":53},"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 ",{"type":15,"tag":55,"props":56,"children":57},"em",{},[58],{"type":20,"value":59},"added to the home screen and launched from the home screen icon",{"type":20,"value":61},", 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.",{"type":15,"tag":16,"props":63,"children":64},{},[65,71,73,79,81,87,88,94],{"type":15,"tag":66,"props":67,"children":69},"code",{"className":68},[],[70],{"type":20,"value":32},{"type":20,"value":72}," is a tiny Express server (",{"type":15,"tag":66,"props":74,"children":76},{"className":75},[],[77],{"type":20,"value":78},"web-push",{"type":20,"value":80},", ",{"type":15,"tag":66,"props":82,"children":84},{"className":83},[],[85],{"type":20,"value":86},"express",{"type":20,"value":80},{"type":15,"tag":66,"props":89,"children":91},{"className":90},[],[92],{"type":20,"value":93},"qrcode-terminal",{"type":20,"value":95},") plus a service worker and a debug UI. The server generates VAPID keys on first run and persists them:",{"type":15,"tag":97,"props":98,"children":102},"pre",{"className":99,"code":100,"language":101,"meta":7,"style":7},"language-js shiki shiki-themes github-dark","import webpush from 'web-push';\n\nif (!state.vapid) {\n  state.vapid = webpush.generateVAPIDKeys();\n}\nwebpush.setVapidDetails('mailto:test@example.com', state.vapid.publicKey, state.vapid.privateKey);\n","js",[103],{"type":15,"tag":66,"props":104,"children":105},{"__ignoreMap":7},[106,140,150,174,204,213],{"type":15,"tag":107,"props":108,"children":111},"span",{"class":109,"line":110},"line",1,[112,118,124,129,135],{"type":15,"tag":107,"props":113,"children":115},{"style":114},"--shiki-default:#F97583",[116],{"type":20,"value":117},"import",{"type":15,"tag":107,"props":119,"children":121},{"style":120},"--shiki-default:#E1E4E8",[122],{"type":20,"value":123}," webpush ",{"type":15,"tag":107,"props":125,"children":126},{"style":114},[127],{"type":20,"value":128},"from",{"type":15,"tag":107,"props":130,"children":132},{"style":131},"--shiki-default:#9ECBFF",[133],{"type":20,"value":134}," 'web-push'",{"type":15,"tag":107,"props":136,"children":137},{"style":120},[138],{"type":20,"value":139},";\n",{"type":15,"tag":107,"props":141,"children":143},{"class":109,"line":142},2,[144],{"type":15,"tag":107,"props":145,"children":147},{"emptyLinePlaceholder":146},true,[148],{"type":20,"value":149},"\n",{"type":15,"tag":107,"props":151,"children":153},{"class":109,"line":152},3,[154,159,164,169],{"type":15,"tag":107,"props":155,"children":156},{"style":114},[157],{"type":20,"value":158},"if",{"type":15,"tag":107,"props":160,"children":161},{"style":120},[162],{"type":20,"value":163}," (",{"type":15,"tag":107,"props":165,"children":166},{"style":114},[167],{"type":20,"value":168},"!",{"type":15,"tag":107,"props":170,"children":171},{"style":120},[172],{"type":20,"value":173},"state.vapid) {\n",{"type":15,"tag":107,"props":175,"children":177},{"class":109,"line":176},4,[178,183,188,193,199],{"type":15,"tag":107,"props":179,"children":180},{"style":120},[181],{"type":20,"value":182},"  state.vapid ",{"type":15,"tag":107,"props":184,"children":185},{"style":114},[186],{"type":20,"value":187},"=",{"type":15,"tag":107,"props":189,"children":190},{"style":120},[191],{"type":20,"value":192}," webpush.",{"type":15,"tag":107,"props":194,"children":196},{"style":195},"--shiki-default:#B392F0",[197],{"type":20,"value":198},"generateVAPIDKeys",{"type":15,"tag":107,"props":200,"children":201},{"style":120},[202],{"type":20,"value":203},"();\n",{"type":15,"tag":107,"props":205,"children":207},{"class":109,"line":206},5,[208],{"type":15,"tag":107,"props":209,"children":210},{"style":120},[211],{"type":20,"value":212},"}\n",{"type":15,"tag":107,"props":214,"children":216},{"class":109,"line":215},6,[217,222,227,232,237],{"type":15,"tag":107,"props":218,"children":219},{"style":120},[220],{"type":20,"value":221},"webpush.",{"type":15,"tag":107,"props":223,"children":224},{"style":195},[225],{"type":20,"value":226},"setVapidDetails",{"type":15,"tag":107,"props":228,"children":229},{"style":120},[230],{"type":20,"value":231},"(",{"type":15,"tag":107,"props":233,"children":234},{"style":131},[235],{"type":20,"value":236},"'mailto:test@example.com'",{"type":15,"tag":107,"props":238,"children":239},{"style":120},[240],{"type":20,"value":241},", state.vapid.publicKey, state.vapid.privateKey);\n",{"type":15,"tag":16,"props":243,"children":244},{},[245],{"type":20,"value":246},"Sending a push is then just:",{"type":15,"tag":97,"props":248,"children":250},{"className":99,"code":249,"language":101,"meta":7,"style":7},"await webpush.sendNotification(sub, payload, { TTL: 60 });\n",[251],{"type":15,"tag":66,"props":252,"children":253},{"__ignoreMap":7},[254],{"type":15,"tag":107,"props":255,"children":256},{"class":109,"line":110},[257,262,266,271,276,282],{"type":15,"tag":107,"props":258,"children":259},{"style":114},[260],{"type":20,"value":261},"await",{"type":15,"tag":107,"props":263,"children":264},{"style":120},[265],{"type":20,"value":192},{"type":15,"tag":107,"props":267,"children":268},{"style":195},[269],{"type":20,"value":270},"sendNotification",{"type":15,"tag":107,"props":272,"children":273},{"style":120},[274],{"type":20,"value":275},"(sub, payload, { TTL: ",{"type":15,"tag":107,"props":277,"children":279},{"style":278},"--shiki-default:#79B8FF",[280],{"type":20,"value":281},"60",{"type":15,"tag":107,"props":283,"children":284},{"style":120},[285],{"type":20,"value":286}," });\n",{"type":15,"tag":16,"props":288,"children":289},{},[290,292,298,300,306],{"type":20,"value":291},"The client subscribes with that public VAPID key, posts the subscription back to the server, and the service worker handles the ",{"type":15,"tag":66,"props":293,"children":295},{"className":294},[],[296],{"type":20,"value":297},"push",{"type":20,"value":299}," event with ",{"type":15,"tag":66,"props":301,"children":303},{"className":302},[],[304],{"type":20,"value":305},"showNotification",{"type":20,"value":307},".",{"type":15,"tag":309,"props":310,"children":312},"h3",{"id":311},"the-https-problem-solved-with-tailscale",[313],{"type":20,"value":314},"The HTTPS problem, solved with Tailscale",{"type":15,"tag":16,"props":316,"children":317},{},[318,320,326],{"type":20,"value":319},"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 ",{"type":15,"tag":66,"props":321,"children":323},{"className":322},[],[324],{"type":20,"value":325},"tailscale serve",{"type":20,"value":327},":",{"type":15,"tag":97,"props":329,"children":333},{"className":330,"code":331,"language":332,"meta":7,"style":7},"language-bash shiki shiki-themes github-dark","tailscale serve --bg --https=443 http://localhost:3000\n","bash",[334],{"type":15,"tag":66,"props":335,"children":336},{"__ignoreMap":7},[337],{"type":15,"tag":107,"props":338,"children":339},{"class":109,"line":110},[340,345,350,355,360],{"type":15,"tag":107,"props":341,"children":342},{"style":195},[343],{"type":20,"value":344},"tailscale",{"type":15,"tag":107,"props":346,"children":347},{"style":131},[348],{"type":20,"value":349}," serve",{"type":15,"tag":107,"props":351,"children":352},{"style":278},[353],{"type":20,"value":354}," --bg",{"type":15,"tag":107,"props":356,"children":357},{"style":278},[358],{"type":20,"value":359}," --https=443",{"type":15,"tag":107,"props":361,"children":362},{"style":131},[363],{"type":20,"value":364}," http://localhost:3000\n",{"type":15,"tag":16,"props":366,"children":367},{},[368,370,376],{"type":20,"value":369},"That puts your local app on ",{"type":15,"tag":66,"props":371,"children":373},{"className":372},[],[374],{"type":20,"value":375},"https://\u003Cmachine>.\u003Ctailnet>.ts.net",{"type":20,"value":377}," 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.",{"type":15,"tag":16,"props":379,"children":380},{},[381,383,388,389,394,395,400,402,407],{"type":20,"value":382},"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 ",{"type":15,"tag":55,"props":384,"children":385},{},[386],{"type":20,"value":387},"is installed",{"type":20,"value":80},{"type":15,"tag":55,"props":390,"children":391},{},[392],{"type":20,"value":393},"is standalone",{"type":20,"value":80},{"type":15,"tag":55,"props":396,"children":397},{},[398],{"type":20,"value":399},"permission state",{"type":20,"value":401},", and ",{"type":15,"tag":55,"props":403,"children":404},{},[405],{"type":20,"value":406},"subscription state",{"type":20,"value":408},". 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.",{"type":15,"tag":43,"props":410,"children":412},{"id":411},"notifirr-one-icon-per-sender",[413],{"type":20,"value":414},"Notifirr: one icon per sender",{"type":15,"tag":16,"props":416,"children":417},{},[418,420,425],{"type":20,"value":419},"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 ",{"type":15,"tag":28,"props":421,"children":422},{},[423],{"type":20,"value":424},"one PWA per logical sender",{"type":20,"value":426},", each with its own icon, name, and colour, all served from a single backend.",{"type":15,"tag":16,"props":428,"children":429},{},[430,432,438,440,446],{"type":20,"value":431},"The result is ",{"type":15,"tag":66,"props":433,"children":435},{"className":434},[],[436],{"type":20,"value":437},"notifirr -A sonarr \"Done\" \"library scan complete\"",{"type":20,"value":439}," lighting up an orange Sonarr-skinned icon, while ",{"type":15,"tag":66,"props":441,"children":443},{"className":442},[],[444],{"type":20,"value":445},"notifirr -A ci \"Build #4421\" \"passed\"",{"type":20,"value":447}," lights up a different icon, even though it's all one container.",{"type":15,"tag":16,"props":449,"children":450},{},[451],{"type":20,"value":452},"The stack is deliberately boring server-side:",{"type":15,"tag":454,"props":455,"children":456},"ul",{},[457,475,485,495,505],{"type":15,"tag":458,"props":459,"children":460},"li",{},[461,466,468,473],{"type":15,"tag":28,"props":462,"children":463},{},[464],{"type":20,"value":465},"Laravel 11 / PHP 8.3",{"type":20,"value":467}," behind ",{"type":15,"tag":28,"props":469,"children":470},{},[471],{"type":20,"value":472},"FrankenPHP",{"type":20,"value":474}," (Caddy + PHP in one binary)",{"type":15,"tag":458,"props":476,"children":477},{},[478,483],{"type":15,"tag":28,"props":479,"children":480},{},[481],{"type":20,"value":482},"Postgres",{"type":20,"value":484}," as both database and queue, no Redis",{"type":15,"tag":458,"props":486,"children":487},{},[488,493],{"type":15,"tag":28,"props":489,"children":490},{},[491],{"type":20,"value":492},"Livewire + Blade",{"type":20,"value":494}," for the admin UI",{"type":15,"tag":458,"props":496,"children":497},{},[498,503],{"type":15,"tag":28,"props":499,"children":500},{},[501],{"type":20,"value":502},"Sanctum",{"type":20,"value":504}," API tokens, optionally scoped to a single app",{"type":15,"tag":458,"props":506,"children":507},{},[508,513],{"type":15,"tag":28,"props":509,"children":510},{},[511],{"type":20,"value":512},"minishlink/web-push",{"type":20,"value":514}," for VAPID and payload encryption",{"type":15,"tag":16,"props":516,"children":517},{},[518,520,526,527,533,535,541,543,549],{"type":20,"value":519},"Each \"app\" renders its own ",{"type":15,"tag":66,"props":521,"children":523},{"className":522},[],[524],{"type":20,"value":525},"manifest.json",{"type":20,"value":80},{"type":15,"tag":66,"props":528,"children":530},{"className":529},[],[531],{"type":20,"value":532},"sw.js",{"type":20,"value":534},", and bootstrap script at ",{"type":15,"tag":66,"props":536,"children":538},{"className":537},[],[539],{"type":20,"value":540},"/app/\u003Cslug>/",{"type":20,"value":542},", which is what gives every PWA its distinct identity. Sending happens through a ",{"type":15,"tag":66,"props":544,"children":546},{"className":545},[],[547],{"type":20,"value":548},"SendWebPushJob",{"type":20,"value":550}," on the queue; the dispatcher fans a single POST out to every device subscribed to the target app.",{"type":15,"tag":97,"props":552,"children":556},{"className":553,"code":554,"language":555,"meta":7,"style":7},"language-sh shiki shiki-themes github-dark","notifirr \"Deploy finished\" \"production is live\"\nlong-running-command && notifirr -A sonarr \"Done\" \"library scan complete\"\n","sh",[557],{"type":15,"tag":66,"props":558,"children":559},{"__ignoreMap":7},[560,578],{"type":15,"tag":107,"props":561,"children":562},{"class":109,"line":110},[563,568,573],{"type":15,"tag":107,"props":564,"children":565},{"style":195},[566],{"type":20,"value":567},"notifirr",{"type":15,"tag":107,"props":569,"children":570},{"style":131},[571],{"type":20,"value":572}," \"Deploy finished\"",{"type":15,"tag":107,"props":574,"children":575},{"style":131},[576],{"type":20,"value":577}," \"production is live\"\n",{"type":15,"tag":107,"props":579,"children":580},{"class":109,"line":142},[581,586,591,595,600,605,610],{"type":15,"tag":107,"props":582,"children":583},{"style":195},[584],{"type":20,"value":585},"long-running-command",{"type":15,"tag":107,"props":587,"children":588},{"style":120},[589],{"type":20,"value":590}," && ",{"type":15,"tag":107,"props":592,"children":593},{"style":195},[594],{"type":20,"value":567},{"type":15,"tag":107,"props":596,"children":597},{"style":278},[598],{"type":20,"value":599}," -A",{"type":15,"tag":107,"props":601,"children":602},{"style":131},[603],{"type":20,"value":604}," sonarr",{"type":15,"tag":107,"props":606,"children":607},{"style":131},[608],{"type":20,"value":609}," \"Done\"",{"type":15,"tag":107,"props":611,"children":612},{"style":131},[613],{"type":20,"value":614}," \"library scan complete\"\n",{"type":15,"tag":16,"props":616,"children":617},{},[618,620,626,628,633],{"type":20,"value":619},"One thing I designed in from the start: the service layer sits behind contracts and ",{"type":15,"tag":66,"props":621,"children":623},{"className":622},[],[624],{"type":20,"value":625},"ServiceProvider",{"type":20,"value":627},"s, with the Livewire admin and the HTTP API as two independent ",{"type":15,"tag":55,"props":629,"children":630},{},[631],{"type":20,"value":632},"consumers",{"type":20,"value":634}," 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.",{"type":15,"tag":43,"props":636,"children":638},{"id":637},"things-worth-knowing-if-you-try-this",[639],{"type":20,"value":640},"Things worth knowing if you try this",{"type":15,"tag":454,"props":642,"children":643},{},[644,670,680,696],{"type":15,"tag":458,"props":645,"children":646},{},[647,652,654,660,662,668],{"type":15,"tag":28,"props":648,"children":649},{},[650],{"type":20,"value":651},"Subscriptions expire.",{"type":20,"value":653}," A push send can come back ",{"type":15,"tag":66,"props":655,"children":657},{"className":656},[],[658],{"type":20,"value":659},"404",{"type":20,"value":661},"/",{"type":15,"tag":66,"props":663,"children":665},{"className":664},[],[666],{"type":20,"value":667},"410",{"type":20,"value":669}," when a subscription is dead. Drop those endpoints automatically or your sender will accumulate garbage.",{"type":15,"tag":458,"props":671,"children":672},{},[673,678],{"type":15,"tag":28,"props":674,"children":675},{},[676],{"type":20,"value":677},"Icons cache at install time.",{"type":20,"value":679}," 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.",{"type":15,"tag":458,"props":681,"children":682},{},[683,694],{"type":15,"tag":28,"props":684,"children":685},{},[686,692],{"type":15,"tag":66,"props":687,"children":689},{"className":688},[],[690],{"type":20,"value":691},"AbortError",{"type":20,"value":693}," on subscribe",{"type":20,"value":695}," almost always means the app isn't actually installed yet, or you're below iOS 16.4.",{"type":15,"tag":458,"props":697,"children":698},{},[699,704,706,712],{"type":15,"tag":28,"props":700,"children":701},{},[702],{"type":20,"value":703},"Permission can only be requested from the installed PWA.",{"type":20,"value":705}," If ",{"type":15,"tag":66,"props":707,"children":709},{"className":708},[],[710],{"type":20,"value":711},"Notification.requestPermission()",{"type":20,"value":713}," does nothing, you're in Safari, not the home-screen app.",{"type":15,"tag":16,"props":715,"children":716},{},[717],{"type":20,"value":718},"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.",{"type":15,"tag":720,"props":721,"children":722},"style",{},[723],{"type":20,"value":724},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":7,"searchDepth":142,"depth":142,"links":726},[727,730,731],{"id":45,"depth":142,"text":48,"children":728},[729],{"id":311,"depth":152,"text":314},{"id":411,"depth":142,"text":414},{"id":637,"depth":142,"text":640},"markdown","content:blog:self-hosted-ios-web-push.md","content","blog/self-hosted-ios-web-push.md","blog/self-hosted-ios-web-push","md",[739,743,747,751,755,759,763,767,768,772],{"_path":740,"title":741,"date":742},"/blog/deploying-nuxt-to-cloudflare-workers","Deploying this Nuxt site to Cloudflare Workers","2026-06-06",{"_path":744,"title":745,"date":746},"/blog/building-forever-llm","Building forever-llm: three takes on a model that never stops","2026-05-20",{"_path":748,"title":749,"date":750},"/blog/laravel-cortex-adhd-productivity","Building Cortex: An ADHD Productivity App in Laravel + Inertia","2026-05-12",{"_path":752,"title":753,"date":754},"/blog/eink-spotify-weather-clock","Building an E-Ink Clock That Shows Spotify, Weather and the Time","2026-05-05",{"_path":756,"title":757,"date":758},"/blog/hetzner-k8s-cluster","Building a K3s Cluster on Hetzner with Terraform and GitOps","2026-04-20",{"_path":760,"title":761,"date":762},"/blog/arduino-uno-q-forza-rev-gauge","A Forza Rev Gauge on the Arduino UNO Q's LED Matrix","2026-04-15",{"_path":764,"title":765,"date":766},"/blog/modular-go-echo-gorm","A Modular Go Web App Pattern with Echo, GORM and golang-migrate","2026-04-05",{"_path":4,"title":8,"date":10},{"_path":769,"title":770,"date":771},"/blog/lit-web-components-astro-docs","Building a framework-free web component library with Lit and Astro","2026-03-10",{"_path":773,"title":774,"date":775},"/blog/welcome-to-my-blog","About this site","2024-01-15",1781294950692]