Self-Host ntfy for Push Notifications From Your Bots and Scripts
If you want your bot, cron script, or backup job to buzz your phone, self-hosting ntfy is one of the best-fitting workloads on a small container. It's a pub/sub notification server: you POST a string to a topic URL, and every subscribed device gets a push. The server "comes as a statically linked binary" (ntfy docs) with no database server behind it, so it sits idle using very little until something happens. Deploy the official image, bind port 80 as HTTPS, attach one small volume, and set auth-default-access to deny-all before you publish anything — that last step is the one most guides bury.
Why this fits a small container
A lot of "self-host X" advice quietly assumes a machine with 4 GB of RAM. ntfy doesn't: it's a single Go binary, its persistence is a SQLite file, and a notification server spends almost all of its time doing nothing. That's the profile that works well here — small, long-running, mostly idle.
The realistic constraints are availability and app count, not memory. A notification service is only dependable if it is up when something fires, so it needs credit on the balance like anything else you keep running — and if you're already running a bot, ntfy is a second app drawing on the same account limits.
Deploying it
Use the Existing Image path, which lets you set the image, command, ports, volumes and environment variables in one go rather than creating the app and then editing it:
- Image:
binwiederhier/ntfy - Run command:
serve - Port: container port
80, protocol HTTPS — ntfy "exposes its web UI and the API on port 80" - Volume:
/var/cache/ntfy
One detail worth knowing, because it applies to every image you deploy this way: the run command replaces the image's command, not its entrypoint. The ntfy image's entrypoint is already ntfy, so the command is serve — writing ntfy serve gets you ntfy ntfy serve and a confusing startup failure. When an existing image won't start and the error looks like a mangled argument list, this is usually why.
Binding the port as HTTPS gets you a valid certificate automatically, which matters here: the mobile apps expect to talk to your server over HTTPS, and you don't want to be provisioning certificates for a notification server.
The environment variables that matter
Every ntfy setting has an environment variable with an NTFY_ prefix, which is simpler than mounting a config file — though you can mount server.yml as a config file if you'd rather keep it in version control.
| Variable | Value | Why |
|---|---|---|
NTFY_BASE_URL |
your app's public https://… URL |
Used to build attachment and subscription URLs |
NTFY_CACHE_FILE |
/var/cache/ntfy/cache.db |
Message cache; on the volume so it survives redeploys |
NTFY_AUTH_FILE |
/var/cache/ntfy/user.db |
User database — same volume |
NTFY_AUTH_DEFAULT_ACCESS |
deny-all |
The important one, see below |
NTFY_BEHIND_PROXY |
true |
There's a reverse proxy in front of your container |
NTFY_BEHIND_PROXY is not optional cosmetics. ntfy's documentation is blunt about it: if you're running behind a proxy and don't set it, "all visitors are rate limited as if they are one." Without it, ntfy sees the proxy's address for every request and its per-IP rate limiting collapses into a single shared bucket.
The awkward one is NTFY_BASE_URL, because the app's hostname is generated when the app is created — you don't know it in advance. Create the app, read the URL off the panel, set the variable, restart. (If you'd rather not hard-code it, the platform injects the app's own public URLs as DF_URLS, which a startup command can read.)
Anything outside the volume is discarded when the container is recreated, which happens on every redeploy — so keep both SQLite files on it.
Lock it down before you publish anything
This is the part to get right. Every port binding here is public — there's no private networking and no IP filtering, so the moment your app starts, your ntfy server is reachable from the internet.
ntfy's default is open: anyone who knows or guesses a topic name can read it and publish to it. Topic names are the only secret, and they travel in URLs. The fix is in ntfy's own documentation — "if you are setting up a private instance, you'll want to set this to deny-all" — after which nothing works until you create a user.
Do that from the shell in the panel:
ntfy user add --role=admin admin
ntfy user list
Then publish with credentials:
curl -u "admin:yourpassword" -d "Backup finished" https://your-app.example/backups
Set deny-all from the start rather than after testing. An open notification endpoint on a public URL is the kind of thing that gets found by scanners, and the failure mode — someone else pushing notifications to your phone, or reading yours — is unpleasant in a way that's hard to undo. Related reading on the same trade-off: protecting a public endpoint.
The iOS caveat, stated honestly
If you use an iPhone, read this before you commit. Apple's background restrictions mean a self-hosted server can't push to the iOS app on its own. ntfy's docs put it plainly: iOS "heavily restricts background processing, which sadly makes it impossible to implement instant push notifications without a central server."
The workaround is upstream-base-url, which forwards a wake-up poll through ntfy.sh so your phone knows to fetch from your server. Without it, "delivery can take hours."
What actually leaves your server is narrow, and worth knowing rather than guessing at: the poll request carries "only the message ID (in the X-Poll-ID header), and the SHA256 checksum of the topic URL." The notification body sent upstream is the literal string New message — your content stays on your instance and the phone fetches it from you directly.
Android has no such problem; the app holds its own connection to your server.
Wiring it to things you already run
The payoff is that any process that can make an HTTP request can now reach your phone:
- Your bot's error handler. One
curl— or a five-line HTTP call — beats discovering a crash a day later. Especially useful because a crash-looping app can still report as running. - Scheduled jobs. A backup or report script that pings a topic on success gives you a heartbeat; silence becomes the alarm. See scheduled tasks without cron.
- Uptime checks. Uptime Kuma has a built-in ntfy notification provider, so the two pair directly.
- Long builds and deploys. A CI step that notifies when a deploy lands.
Use separate topics per source and a Priority header for the ones that should break through a focus mode.
FAQ
What is ntfy?
A pub/sub notification server. You send an HTTP POST to a topic URL and every subscribed device gets a push notification. There are Android, iOS, and web clients.
Does self-hosted ntfy need a database?
No separate database server. It uses SQLite files for the message cache and user database — put both on a volume so they survive redeploys.
Is my ntfy server exposed to the internet?
Yes. Every port binding is public, so set auth-default-access to deny-all and create users before you rely on it.
Why is my ntfy app failing to start?
Check the run command. It replaces the image's command, not its entrypoint, so it should be serve — not ntfy serve.
Do push notifications work on iPhone?
Only if you configure upstream-base-url, because iOS background restrictions prevent a self-hosted server from delivering instantly. Just the message ID and a hash of the topic URL go upstream, not your content.
How much does it cost to run?
It's a small idle Go process, so the cost is based on the resources you allocate rather than on how many notifications it sends.
Wrapping up
Most self-hosting projects ask for more machine than a small container has. ntfy asks for a binary, a volume, and about four environment variables — and in exchange every script, bot, and job you run gets a way to reach you. Set deny-all before you publish, put both SQLite files on the volume, and if you're on iOS, configure the upstream relay so notifications arrive in seconds rather than hours.