Post

Self-Hosting ntfy in Docker Behind Nginx Proxy Manager

Run a private ntfy push notification server in Docker, protect it with access tokens, and wire it into Nginx Proxy Manager with TLS and a phone app.

My Nextcloud backup job has been sending me completion and failure alerts through the hosted ntfy.sh service for a while now, and it worked fine. But every “backup finished” message meant a plaintext HTTP POST leaving my network and transiting through someone else’s server. For a notification that only says “backup ok” that is harmless, but I did not want to think about what I might be tempted to send through it later. So when I finally sat down on a rainy Saturday, I gave the notification path itself the same treatment as everything else in my homelab: run the server at home.

This post covers how I put ntfy into a Docker container, locked it down with a user account and a write-only access token, put it behind my existing Nginx Proxy Manager setup for TLS and DNS, and pointed the Android app at it. If you have not set up NPM yet, read that article first, because I reuse its wildcard certificate, its Docker network pattern, and its proxy host workflow here.


What ntfy Is and Why Self-Host It

ntfy (pronounced “notify”) is a pub-sub notification service built on plain HTTP. You publish a message with a simple PUT or POST request to a topic URL, and any client subscribed to that topic gets an instant push notification on their phone. No broker protocol, no SMTP gymnastics, no Telegram bot token. From a script’s perspective it is one line of curl, which is exactly why I reached for it.

The delivery to the phone is the interesting part. The mobile app keeps a subscription open to the server, and the server relays notifications into the Android or iOS notification system via Firebase. That means alerts still arrive when the app is closed, which my earlier attempts with home-grown notify-send over SSH never managed.

So why not just use the free hosted ntfy.sh? It is a perfectly good service and the free tier is generous. I self-hosted for three reasons:

  • Privacy: my messages sometimes contain hostnames, paths, and job details. That metadata stays in my LAN.
  • No artificial limits: the public server rate-limits by IP and caps message history. On my own box I decide.
  • Independence: backup alerts from machines that should not need a route to the public internet anyway. In my case the backup LXC talks to ntfy.internal on the LAN and nothing leaves the house.

The trade-off I had to accept: push delivery to the phone goes through Firebase no matter who hosts the server, so the phone does need internet. Self-hosting keeps message content private (Firebase only carries a notification trigger), but it does not make the delivery path 100% air-gapped. I was fine with that; know what you are buying before you spend the weekend on it.

Here is how the request path looks once everything is wired up:

flowchart LR
    SCRIPT["Scripts & cron jobs<br/>curl + access token"]
    APP["ntfy app on phone<br/>Firebase push"]
    NPM["Nginx Proxy Manager<br/>TLS + Let's Encrypt"]
    NTFY["ntfy container<br/>binwiederhier/ntfy"]

    SCRIPT -- "POST https://ntfy.example.com/topic" --> NPM
    NPM -- "proxy_pass :80 (docker network)" --> NTFY
    NTFY -- "stream / WebSocket" --> NPM
    NPM -- "HTTPS + Firebase" --> APP

Prerequisites and Assumptions

I wrote this for the environment I actually run, so here are my assumptions:

  • A Linux host with Docker and the Docker Compose plugin.
  • Nginx Proxy Manager already installed and terminating TLS with a Let’s Encrypt certificate, as described in my NPM article.
  • A DNS record for something like ntfy.yourdomain.com pointing at the machine running NPM (or your router’s port-forwarded public IP).
  • An Android or iOS phone for the app part.

ntfy is a Go binary that listens on one port and keeps its state in SQLite files. If your NPM and your containers cannot reach each other on a shared Docker network, sort that out first; every symptom of a broken proxy chain looks like an ntfy bug otherwise.


Running ntfy in Docker

The official image is binwiederhier/ntfy on Docker Hub. I pinned v2.28, which was the current stable release at the time of writing (v2.28.0, released August 27, 2026). Pinning a minor tag instead of latest is my habit for anything that also runs on a phone client, because a silent server upgrade that outruns the app’s supported features is a miserable way to spend an evening.

Before I paste the compose file: my goal was a server that requires authentication for everything, publishes nothing to the host network, and is only reachable through NPM. ntfy’s configuration is friendly to that, because every config option can be set via an NTFY_ environment variable. That keeps the whole deployment in one compose file with a single bind mount for state.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
services:
  ntfy:
    image: binwiederhier/ntfy:v2.28
    container_name: ntfy
    command: serve
    environment:
      - NTFY_BASE_URL=https://ntfy.example.com
      - NTFY_LISTEN_HTTP=:80
      - NTFY_BEHIND_PROXY=true
      - NTFY_ENABLE_LOGIN=true
      - NTFY_REQUIRE_LOGIN=true
      - NTFY_AUTH_FILE=/data/db/auth.db
      - NTFY_CACHE_FILE=/data/db/cache.db
      - NTFY_ATTACHMENT_CACHE_DIR=/data/attachments
      - NTFY_AUTH_DEFAULT_ACCESS=deny-all
      - TZ=Europe/Vienna
    volumes:
      - ./data:/data
    restart: unless-stopped
    networks:
      - npm

networks:
  npm:
    name: npm
    external: true

A few decisions in there that mattered more than the rest:

  • NTFY_REQUIRE_LOGIN=true makes both publishing and subscribing anonymous requests return HTTP 401. This is the switch that stops the “anyone who guesses my topic can spam me” problem. Combined with deny-all as the default access policy, even a registered user can only touch topics I explicitly grant.
  • NTFY_LISTEN_HTTP=:80 with no ports: mapping. The container never touches a host port. NPM talks to it over the shared npm Docker network, exactly the pattern I used in the Portainer article for managed containers. Nothing on the LAN can bypass the proxy by hitting an exposed port, because there is no exposed port.
  • NTFY_BEHIND_PROXY=true makes ntfy trust X-Forwarded-For and X-Forwarded-Proto from NPM, which matters for correct rate limiting and for /app (the built-in web UI) to generate right URLs.
  • The SQLite files live under ./data, so a tar of that directory is the entire backup of users, tokens, and message cache.
1
2
3
mkdir -p ~/ntfy/data && cd ~/ntfy
docker compose up -d
docker compose logs ntfy | head -20

You should see a startup line mentioning server is listening on port 80 and no errors about the auth file. The container also ships the ntfy CLI, which is how we create the first user in a moment.


Users, Topics, and a Write-Only Token

With login required, the server needs at least one account before the phone app can subscribe. I gave myself a normal user for the app, and a separate access token for scripts. That split turned out to be the most useful security decision of the whole setup:

  • The app logs in with the user account and subscribes (reads).
  • Cron jobs, backup scripts, and monitoring only ever get a token that can write to one topic. A leaked token on some random VPS can annoy me with fake notifications, but it cannot read my message history or discover what other topics exist.
1
2
3
4
5
6
7
8
9
10
11
# create the human user (prompts for password)
docker exec -it ntfy ntfy user add --role=admin manuel

# create a write-only token for a specific topic
docker exec -it ntfy ntfy token create manuel \
  --label "backup scripts" \
  --write-only \
  --topics homelab-backups

# grant the topic to the user so the app can subscribe
docker exec -it ntfy ntfy access manuel homelab-backups rw

The token printout (tk_...) is shown once; I stored it in my password manager immediately. Verify the state with:

1
2
3
docker exec -it ntfy ntfy user list
docker exec -it ntfy ntfy access list
docker exec -it ntfy ntfy token list

Publishing with the token is a normal curl call with one extra header. I updated the notify function in my backup script from the hosted URL to the local one:

1
2
3
curl -H "Authorization: Bearer tk_XXXX..." \
     -d "Backup finished: 3.2 GiB in 4m12s" \
     https://ntfy.example.com/homelab-backups

Keep write-only tokens separate per use case. I created a second token for “monitoring” and a third for “fun scripts”. When one leaks or a project dies, I delete that single token instead of rotating a shared secret and chasing half-broken cron jobs.


Wiring ntfy into Nginx Proxy Manager

This is where the article circles back to my NPM setup, so log into the NPM admin UI and add a proxy host the usual way:

  1. Scheme http, Forward host ntfy, Forward port 80. Because both containers sit on the external npm network, the container name resolves directly. No host IP, no port juggling.
  2. SSL tab: pick the existing wildcard Let’s Encrypt certificate for your domain, enable Force SSL.
  3. Advanced tab: this is the part that bit me. ntfy delivers messages to subscribers by holding HTTP connections open for a long time (streaming JSON or WebSockets). Nginx’s default proxy settings want to buffer responses, which quietly breaks real-time delivery.

I initially tested without touching the Advanced tab, watched curl hang, saw nothing arrive on my phone for minutes at a time, and assumed the token was wrong. It was not. Messages were fine; buffering was strangling the long-lived subscription stream and websocket upgrade. Paste this into the custom configuration box:

1
2
3
4
5
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 600s;

The Upgrade/Connection pair is what makes WebSockets work (NPM defines $connection_upgrade globally, so referencing $http_connection with the map is safe here). proxy_buffering off is the line that actually fixed my subscriptions. The longer read timeout stops the proxy from closing idle streams every 60 seconds and forcing the app into a reconnect loop.

Test the full chain from the server itself, which bypasses DNS and isolates whether NPM or something upstream is at fault:

1
2
3
4
curl -k -H "Authorization: Bearer tk_XXXX..." \
  -d "test via npm" \
  https://localhost/homelab-backups \
  --resolve localhost:443:127.0.0.1

Then the real thing from any machine:

1
2
3
curl -H "Authorization: Bearer tk_XXXX..." \
  -d "hello from the internet side" \
  https://ntfy.example.com/homelab-backups

A 200 with the stored message JSON back means the whole path works: token accepted, topic granted, proxy happy.


Pointing the Smartphone App at Your Server

In the Android (or iOS) app, open the navigation drawer, tap the server picker at the top, choose Add server, and enter https://ntfy.example.com with the user account credentials from ntfy user add. Do not log in on the hosted ntfy.sh selection, and do not skip the https:// prefix; the app is picky about bare hostnames.

After the login succeeds, subscribe to homelab-backups, send a test message with the curl command above, and the notification should land within a second or two. Android did exactly that for me. On iOS I later read that the Firebase background path is the same, so the flow is identical.

One small thing worth doing: in the app’s settings for the self-hosted server, the sync of subscriptions happens against your own cache file (/data/db/cache.db), so messages survive an app restart only as long as you configured them. The default one-week cache is more than enough for notifications; I left it alone.


Testing and Troubleshooting

When something misbehaves, work the chain from the inside out. This is the order I use, because it separates “ntfy broken”, “proxy broken”, and “client wrong” in three commands:

1
2
3
4
5
6
7
8
9
# 1. Server-side publish, container network, ignores NPM entirely
docker exec -it ntfy ntfy publish homelab-backups "direct publish works"

# 2. Subscribe through the proxy from a terminal (Ctrl-C to end)
curl -u manuel:PASSWORD https://ntfy.example.com/homelab-backups/json

# 3. Publish with the token, full path, should arrive in the open subscribe
curl -H "Authorization: Bearer tk_XXXX..." -d "crosses the wire" \
  https://ntfy.example.com/homelab-backups

The failure modes I actually hit, with what they meant:

  • 401 unauthorized on publish, token definitely correct: the topic was not granted to the token’s --topics list. ntfy token list shows the scope; I had typed the topic name with a typo and spent an embarrassing amount of time on the proxy logs before checking.
  • 403 forbidden: user and topic are fine, but the access entry is missing or set to ro. Run ntfy access chris homelab-backups rw again.
  • Publishing works, app gets nothing (or delayed messages): proxy buffering. Recheck the Advanced tab survived an NPM UI edit; NPM rewrites the vhost config on every save, so custom config lives inside the generated file and gets replaced if you edit the host in the GUI and forget to re-paste it.
  • App says “connection lost” every minute or so: the websocket upgrade headers are missing or a proxy_read_timeout is too short. Watch docker logs -f ntfy while the app claims to be connected; if you never see a subscribe request arrive, the upgrade never happened.
  • /app web UI redirects to the wrong domain: NTFY_BASE_URL does not match the URL you actually visit. It is the one variable where the value must be exact, including https and the absence of a trailing slash.

If the app is connected but messages stop arriving after your router reboots, check the NPM host still has the certificate: an expired Let’s Encrypt renewal on the proxy looks exactly like a dead ntfy server from the phone’s point of view.


🔗 References


Final Thoughts

The whole setup took me about an hour, and forty of those minutes were the buffering puzzle, which is entirely my own fault for not reading the “behind a proxy” note in the docs before testing. The result is worth it: every alert my homelab generates now travels from a script, over the LAN, into a 40 MB Go binary I control, and out to my phone through Firebase. I can docker compose down the whole notification layer and lose nothing but an hour of cached messages.

A few things I would do differently, or at least think about before you copy me blindly:

  • If your NPM box and your services are on the same Docker host, the no-published-ports pattern I used is clean. The moment ntfy lives on a different machine than NPM, you need to open a port to the proxy anyway, and then firewalling it to the proxy’s IP becomes part of the design.
  • Attachments are tempting (the docs show camera snapshots pushed straight to your phone) but I deliberately left the attachment directory small and will prune it, because “free notification server” quietly becomes “unbounded disk consumer” otherwise.
  • The public ntfy.sh remains my fallback in scripts that run on VPSes outside my VPN. A write-only token against my server means those hosts still get to buzz me, and nothing else.

If you already run Nginx Proxy Manager, this is about the highest value-per-minute self-hosting project I know. One container, one proxy host, one token, and every cron job you own suddenly has a voice.




Want to help fuel more posts? You know what to do:

Buy Me a Coffee at ko-fi.com
This post is licensed under CC BY 4.0 by the author.