← All Writing
May 8, 20267 min read

How I Built Self-Hosted Push Alerts to My Phone

My home server needed to buzz my phone the instant something breaks — not send an email I’d see three hours later. So I self-hosted ntfy, locked it to my own devices over a personal VPN, and now my scripts push straight to my pocket with no third-party service in the middle

YieldA private push-notification channel my home server can publish to — instant phone alerts for power loss and anything else worth interrupting me for, delivered without any third-party push provider
DifficultyIntermediate (self-hosting a service, VPN networking, firewall rules, token auth, shell scripting)
Total Cook Time~2 hours: install + config (~30 min) + VPN and firewall lockdown (~30 min) + auth (~20 min) + migrating the first alert and debugging a boot crash (~40 min)

Ingredients

The Problem: Alerts That Arrive Too Late

My home server runs a pile of unattended jobs — market emails, finance scrapers, bots. When one of them breaks, or when the machine loses AC power and starts draining its battery, I want to know now. My original setup emailed me. And email is fine for a daily digest, but it’s a terrible way to learn your server is 90 seconds from shutting down. The alert lands in a folder, I see it during my next inbox check, and by then the thing I could have prevented already happened.

I wanted a real interruption — the kind of buzz you get from a text message. The obvious answer is a push-notification service, but the popular ones route your messages through someone else’s cloud. I didn’t love the idea of my “the server is dying” alerts passing through a third party’s servers, tied to an account they could rate-limit or shut off. So the goal became: instant push, but self-hosted and private to my own devices.

What ntfy Actually Does

ntfy (pronounced “notify”) is gloriously simple. It’s a small server that listens for web requests and forwards them to subscribed phones as push notifications. A script sends a message to a named topic (a channel — think of it like a private chat room name), and every device subscribed to that topic gets a push. That’s the whole model.

The magic is that publishing is just a single web request. Any script that can make an HTTP call — which is every script — can send me a notification with one line. No SDK to install, no app to build, no account to register. And because I’m running the server myself, I control who can publish, who can subscribe, and where it lives on the network.

Why “instant” is instant

The phone app doesn’t poll for new messages every few minutes. It holds a WebSocket open — a persistent two-way connection that stays live in the background — so the moment my server publishes, the notification is already on my screen. In practice it beats the buzz of an incoming text.

How the Pieces Fit Together

a scriptdetects an eventPOSTntfy serverself-hostedTailscale VPNprivate to my devicesmy phoneinstant pushfirewall — never exposed to the public internet
A script POSTs an alert to the self-hosted ntfy server; my phone holds an open connection and rings instantly. Tailscale keeps the whole path private to my own devices — the firewall makes it invisible to the public internet.

The full path an alert travels is short: a scheduled script on the server detects something, makes one web request to the ntfy server (also running on that same machine), the ntfy server pushes it across my private VPN to my phone, and the phone app rings. Four hops, all of them either on my own hardware or inside my own encrypted network.

Running the Server

~30 minutes

I installed ntfy (version 2.14.0 at the time) and set it up as a systemd service — a background program that Linux keeps running and restarts automatically if it dies or the machine reboots. All of its behavior lives in one config file, which keeps the whole thing auditable: I can read exactly what it’s doing in a single place.

🔧 Developer section: server setup

Keeping It Private: VPN + Firewall

~30 minutes

This is the part I cared about most. A push server that’s reachable from the public internet is a server that strangers can find, probe, and try to spam. I didn’t want mine to have a public front door at all. Two layers make sure it doesn’t.

The first is Tailscale, a personal VPN. It stitches my own devices — the server, my laptop, my phone — into one small private network that only shows up for devices I’ve personally logged into. The server is reachable at a private address inside that network and nowhere else. There is no public URL, no public IP, nothing for a scanner to stumble onto.

The second is the firewall. As a belt-and-suspenders backup, I told UFW (the server’s firewall) to only accept connections to the ntfy port that come from inside the VPN’s private address range. Even if something were misconfigured, a connection from the open internet gets silently dropped before it reaches the app.

The privacy model in one sentence

The VPN keeps the server reachable only from devices I’ve personally logged in — it’s never exposed to the public internet — and the firewall enforces that at a second layer even if I fat-finger a config. To reach my alert channel, you’d first have to be on my private network.

Auth: Deny Everything by Default

~20 minutes

Being private on the network is one lock. Requiring credentials is a second, independent one. ntfy’s access control starts from deny all — by default nobody can publish or subscribe to anything. From there I granted exactly one user access to exactly the topics I use, and nothing else. Even a device that somehow reached the server without permission would get turned away.

🔧 Developer section: access control

Publishing From a Script

Here’s the payoff for all that setup: sending myself an alert is one command. A script reads the token from its locked file, then makes a single web request to a topic. Headers control the title, how urgent the notification is, and which little icon shows up on the lock screen.

🔧 Developer section: the publish call

The first real job I wired up was the AC power alert. A tiny script runs every minute, checks whether the laptop-turned-server is still on wall power, and if it isn’t, fires an urgent push with the current battery level. A lockfile (a marker file that says “already alerted”) makes sure I get exactly one buzz per outage instead of one every minute until the battery dies. When power comes back, the marker clears and it’s armed again.

Migrating Off Email, Safely

I didn’t want to rip out the old email alerts and discover a week later that pushes were silently failing. So every migration follows the same careful pattern: add the push alongside the existing email, run both for about a week, confirm the phone reliably rings on real triggers, and only then remove the email fallback. Belt and suspenders until I trust the new belt.

Never trust a new alert channel on day one

An alerting system that fails silently is worse than no alerting system — it gives you false confidence. Running the old and new channels in parallel for a week means the first few real alerts prove the pipe works before I rely on it alone. If the push never showed up, the email would still catch it.

How It Grew: Starting Small on Purpose

The first version (v0.1) did exactly one thing: install the server, lock it to the VPN with token auth, get the phone app subscribed, and migrate a single alert — AC power loss — with email kept as a fallback. That was deliberate. I’ve learned the hard way that a self-hosted service you can’t fully reason about is a liability, not a feature.

The design leaves obvious room to grow. Topics are organized so a whole family of future alerts has a home without touching the security model: one channel for critical power events, one for testing, and a reserved namespace for everything I’ll add later. Migrating the next alert — a failed scraper, a bot that went quiet — is now a five-line change, because the hard part (a private, authenticated pipe to my pocket) is already built.

The Bug That Cost Me an Evening

The setup worked perfectly — until the server rebooted, and then ntfy refused to start. It took a while to see why. I had originally told the server to bind directly to its VPN address (to listen for connections there specifically). But when the machine boots, the VPN takes a few seconds to come up, and ntfy startedfirst — trying to grab an address that didn’t exist yet. So it crashed on every boot, which is the worst possible time for your alerting system to be down.

The fix was a small mental shift. Instead of binding to the VPN address (which isn’t ready at boot), I told ntfy to listen on all the machine’s network interfaces — which is always available immediately — and let the firewall be the thing that restricts access to the VPN only. Same end result (reachable solely over the private network), but nothing depends on boot timing anymore. The service comes up cleanly every time, and the firewall does the gatekeeping.

Final Output

Phone — Lock Screen, 2:14 AM
# Server yanks off wall power. Cron fires within 60s:

🚨 AC Power Lost
Battery: 41% — ~1-2 min before shutdown.
now · urgent · breaks through Do Not Disturb

# I'm awake, plugging it back in, before the shutdown.

One web request from a shell script, delivered to my pocket over a private network, in the time it takes to buzz.

What went fast

What needed patience

The best part isn’t the tech — it’s that I stopped thinking about it. My server can now reach into my pocket the instant something matters, over a channel I own end to end, with no cloud account in the middle that could throttle it or go down. It buzzes, I look, I fix it. That’s the whole point.

← Back to all writing