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
Ingredients
- Headless Linux server — the always-on home machine from the earlier posts, running the alert scripts (already set up)
- ntfy — an open-source app that turns a plain web request into a phone push notification, self-hosted so nothing leaves my network (free)
- Tailscale — a personal VPN (virtual private network — a private, encrypted network only my logged-in devices can join) (free tier)
- ntfy Android app — the phone client that holds an open connection and rings the second an alert arrives (free)
- UFW — the server firewall, used to make the notification service invisible to the public internet (free)
- Claude Code — terminal AI for writing the config, the publish scripts, and untangling the boot bug ($200/yr)
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.
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
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.
- Source — a cron job (a task Linux runs automatically on a schedule) notices a problem
- Server — self-hosted ntfy receives the message
- Network — Tailscale carries it privately, never over the public internet
- Client — the ntfy app on my phone shows the push instantly
Running the Server
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
- Install ntfy and enable it as a systemd service so it survives reboots
- All settings live in one YAML config file under
/etc/ntfy/ - It listens on a single local port — but that port is not open to the internet (the firewall handles that, below)
- Point the app on my phone at the server’s private VPN address and subscribe to my topics
Keeping It Private: VPN + Firewall
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 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
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
- Default access set to deny — no anonymous publish, no anonymous subscribe
- Create a single user with a password, and grant it read/write on my topics only
- That user gets an access token — a long random string that acts as a stand-in password for scripts
- Store the token in a permissions-locked file the scripts read at runtime, never pasted inline
- New topic later? Explicitly grant that one topic to that one user — everything stays deny-by-default
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
- Read the token from a permissions-locked file into a variable
- Send a web request carrying an
Authorization: Bearer <token>header (proves the script is allowed to publish) Titleheader — the bold headline of the notificationPriorityheader — set to urgent for power loss so it breaks through Do Not DisturbTagsheader — emoji names that render as icons (a rotating light, a power plug)- The message body is the notification text — battery percentage, timestamp, whatever context I need
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.
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
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
- Publishing from scripts — one web request with an auth header. Every existing script that could send an email could send a push with a near-identical one-liner.
- The phone client — install the app, point it at the server’s private address, subscribe to a topic. Instant delivery worked on the first try.
- Deny-by-default auth — starting from “nobody can do anything” and granting one user one set of topics is a small config and a clean security story.
What needed patience
- The boot-order crash — binding to the VPN address raced against the VPN coming up at boot. Switching to “listen everywhere, restrict with the firewall” fixed it, but finding the timing dependency took real debugging.
- Getting the privacy model right — it’s tempting to just expose a port and move on. Doing it properly meant two independent layers (VPN membership and a firewall rule) so a single mistake can’t open the door.
- Trusting the new channel — resisting the urge to delete the email fallback immediately. Running both for a week is boring and correct; a silent alerting failure is the exact thing you’re trying to avoid.
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.