Use case · Push notifications

A self-hosted ntfy server on a dedicated machine

A self-hosted ntfy server is one Go binary, installed from the project's Debian/Ubuntu repository or run from the binwiederhier/ntfy Docker image, configured in /etc/ntfy/server.yml and placed behind a TLS proxy. Out of the box every topic is open to anyone who can reach the port, so a private instance needs an auth file and auth-default-access: deny-all before it faces the internet. iPhone subscribers add one more setting, upstream-base-url, which relays a content-free wake-up through ntfy.sh.

See the machines

What ntfy needs from a machine

The documentation gives no CPU or RAM figure. The server ships as a statically linked binary, as deb and rpm packages and as a Docker image, for amd64, armv7 and arm64. It needs no database server: by default each store is an SQLite file, with PostgreSQL as an option through database-url. The scaling notes say a home server need not worry about scale; the limit that bites is open files, because every subscriber holds a connection. The packaged systemd unit sets LimitNOFILE to 10,000, and the docs say a proxy in front typically needs an open-files limit double the connections you want to carry.

SettingDefaultPrivate instance behind nginx
listen-http:80127.0.0.1:2586
cache-fileunset: messages kept in memory for 12 h/var/cache/ntfy/cache.db
attachment-cache-dirunset: attachments disabled/var/cache/ntfy/attachments
auth-fileunset: no access control/var/lib/ntfy/user.db
auth-default-accessread-writedeny-all
behind-proxyfalsetrue
upstream-base-urlunsethttps://ntfy.sh, only for iPhone subscribers

Choosing the machine

Our reasoning, not the project's: an alerting server should not share a failure with the systems it reports on, so give it a box of its own. On the dedicated server range, the Moonshot cartridges are sold as one service per machine, which is the shape of this job, and a racked machine is handed over in about 30 minutes once payment is approved. Messages are capped at 4K by default, so bandwidth only matters if you enable attachments; every line marked Unmetered has no transfer allowance either way. Pick the city nearest the phones and scripts that will use it from the five data centres.

Two caveats. Avoid the ARM cartridge built on X-Gene: it installs only Ubuntu 16.04 and 18.04 (see the FAQ). And cartridges other than the m510 and m710x have power control only; every racked single-, dual- and quad-socket server has a full out-of-band console, which is what rescues you from a firewall rule that locks out SSH.

ntfy's release notes say the 2.33.0 amd64 binaries crashed on processors without x86-64-v3 (AVX2/BMI2) and that 2.33.1, released 23 October 2026, fixed it. Our own quick check, not the project's: if grep -c avx2 /proc/cpuinfo prints 0, the processor lacks AVX2, so never pin 2.33.0; install 2.33.1 or later.

Firewall first, then the package

An unconfigured ntfy accepts anything, so enable ufw before the service ever starts. Set IPV6=yes before adding rules, because ufw writes IPv6 copies only for rules added while it is set. nginx will run on the host as a normal process, so it needs the 80/443 rule; ufw would otherwise block it on IPv4 and IPv6 alike. The repository lines are the install guide's own; the signing key fingerprint it publishes is 55BA 774A 6F5E E674 31E4 B6B7 CFDB 962D 4F1E C4AF.

sudo apt update && sudo apt install -y ufw
sudo sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
sudo ufw allow OpenSSH
sudo ufw --force enable
sudo ufw allow 80,443/tcp

sudo mkdir -p /etc/apt/keyrings
sudo curl -L -o /etc/apt/keyrings/ntfy.gpg https://archive.ntfy.sh/apt/keyring.gpg
sudo apt install apt-transport-https
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
    | sudo tee /etc/apt/sources.list.d/ntfy.list
sudo apt update
sudo apt install ntfy
sudo cp /etc/ntfy/server.yml /etc/ntfy/server.yml.orig

Per the packaging files in the GitHub repository, the package ships /var/cache/ntfy, its attachments folder and /var/lib/ntfy (listed in .goreleaser.yml); its post-install script then creates the ntfy system user, hands those folders to it with mode 700, and leaves the service stopped on a fresh install.

Prefer Docker? The image is binwiederhier/ntfy with serve as its command. It contains no server.yml, so map /etc/ntfy, /var/cache/ntfy and /var/lib/ntfy as volumes, leave listen-http out (the container listens on 80) and publish it as -p 127.0.0.1:2586:80. A port mapping that names no address listens on every host address, 0.0.0.0 and [::]; that traffic skips ufw's rules on IPv4, and over IPv6 as well once IPv6 is enabled in the Docker daemon. Publishing on 127.0.0.1 only keeps it local on Docker Engine 28 and later (moby issue 45610).

server.yml for a private instance

Replace /etc/ntfy/server.yml with the following, using your own hostname:

base-url: "https://ntfy.example.com"
listen-http: "127.0.0.1:2586"
behind-proxy: true
cache-file: "/var/cache/ntfy/cache.db"
attachment-cache-dir: "/var/cache/ntfy/attachments"
auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all"
enable-login: true
# only needed if anyone subscribes from the iOS app
upstream-base-url: "https://ntfy.sh"
  • base-url is required for attachment links and for the iOS relay, which hashes the full topic URL.
  • behind-proxy makes ntfy read the visitor's address from X-Forwarded-For. Without it, everyone shares the proxy's IP and is rate limited as one visitor.
  • cache-file keeps messages across restarts, so a phone that was offline can still fetch what it missed with since= or poll=1. Retention stays at cache-duration, 12 hours by default.
  • attachment-cache-dir enables uploads, kept 3 hours by default. ntfy takes full control of that folder and deletes files it does not recognise, so never share it.
  • enable-login lets the web app sign in, which a deny-all server needs.

Users, topics and tokens

  1. Start it: sudo systemctl enable --now ntfy, then curl -s http://127.0.0.1:2586/v1/health should answer {"healthy":true,...}. The server creates user.db on first start.
  2. Create yourself as admin: sudo ntfy user add --role=admin alice. It prompts for the password; an admin reads and writes every topic with no ACL entries.
  3. Create a publisher with the least it needs: sudo ntfy user add backupbot, then sudo ntfy access backupbot backups wo for write-only access to one topic. Patterns such as 'alerts_*' work too.
  4. Give scripts a token instead of a password: sudo ntfy token add --label=nightly backupbot. ntfy notes that a token currently grants full access to its user's account, which is why that user should be this narrow.
  5. List users and grants with sudo ntfy access.

A token typed as a command argument is visible in ps and is saved to shell history. Put the header in a file instead, Authorization: Bearer tk_... in /root/.ntfy-header with mode 600, and publish with curl -H @/root/.ntfy-header -d "backup done" https://ntfy.example.com/backups. If you use UnifiedPush apps on a deny-all server, the docs say to allow anonymous write-only access to their topics: sudo ntfy access '*' 'up*' write-only.

nginx settings the docs depend on

Subscriptions are long-lived HTTP streams and websockets, so the proxy must pass them through untouched. Install nginx and certbot first (sudo apt install nginx certbot python3-certbot-nginx). These are the values in the documentation's nginx example, which mirrors how ntfy.sh itself is set up:

DirectiveValuePurpose
proxy_passhttp://127.0.0.1:2586the loopback listener from server.yml
proxy_http_version1.1required for the websocket upgrade
proxy_set_header Upgrade / Connection$http_upgrade / "upgrade"the docs: be sure to enable WebSockets
proxy_set_header Host / X-Forwarded-For$http_host / $proxy_add_x_forwarded_forreal client address for behind-proxy
proxy_buffering, proxy_request_bufferingoffmessages reach subscribers immediately
proxy_connect / send / read_timeout3moutlasts the 45 s keepalive
client_max_body_size0streams uploads straight to ntfy

The convenient example also accepts plain-HTTP POSTs to topics so short curl commands work. With passwords in play, redirect all HTTP to HTTPS instead, as the docs' stricter variant does; the docs themselves say to use HTTPS so credentials are not exposed. They also note ntfy listens IPv4-only on :80 and that serving IPv4 and IPv6 together means a proxy, so give nginx listen [::]:443 as well. IPv6 is not in any default build here: add it at checkout, at no charge.

iPhones and the ntfy.sh relay

iOS restricts background processing, so the docs say instant notifications are impossible without a central server. With upstream-base-url set, each incoming message makes your server publish a poll request to ntfy.sh. Per the docs, that request carries only the message ID, in an X-Poll-ID header, and the SHA-256 of the topic URL as the upstream topic; the message text in that request is the placeholder "New message", whatever you published. ntfy.sh hands it to Firebase, which forwards it to APNS; the phone wakes, fetches the real message from your server and shows that. The placeholder appears on screen only if the phone cannot reach your server in time. The sample server.yml in the repository says the design keeps the upstream server and Firebase/APNS from reading the message.

Leave it unset and iPhones still get notifications, but the docs warn this can take hours depending on the phone's state, though no more than 20-30 minutes while it is in use. upstream-access-token is needed only if you exceed the upstream rate limits or the upstream server requires authentication. The Android app on a self-hosted server uses its own instant delivery and does not involve Firebase.

Backups and updates

The file worth keeping is user.db: users, ACL entries and tokens. Add server.yml. cache.db only holds messages for the cache duration and attachments expire after 3 hours by default, so losing those costs little. sqlite3's .backup command (part of SQLite, not ntfy) copies a live database consistently. The printf line writes the cron file /etc/cron.d/ntfy-backup; the directory comes first so the first run has somewhere to write.

sudo apt install -y sqlite3
sudo mkdir -m 700 -p /var/backups/ntfy
printf '%s\n' '15 3 * * * root umask 077; sqlite3 /var/lib/ntfy/user.db ".backup /var/backups/ntfy/user-$(date +\%F).db" && cp /etc/ntfy/server.yml /var/backups/ntfy/server-$(date +\%F).yml' | sudo tee /etc/cron.d/ntfy-backup

Copies on the same disk die with the disk: pull /var/backups/ntfy to another machine you control with rsync or scp. To restore, check the copy exists before touching the live file: f=/var/backups/ntfy/user-2026-10-23.db; test -s "$f" && sudo systemctl stop ntfy && sudo install -o ntfy -g ntfy -m 600 "$f" /var/lib/ntfy/user.db && sudo systemctl start ntfy.

Updates are sudo apt update && sudo apt upgrade after reading the release notes. The package's install script restarts ntfy if it was running, and server.yml is shipped as a config file, so your edits are kept. On Docker, docker compose pull then docker compose up -d.

When not to self-host

If a few alerts to yourself are all you need and a public server is acceptable, ntfy.sh takes a curl command and no install at all. Self-hosting buys you private topics, your own retention and no third party holding the message text, but iPhone wake-ups still pass through ntfy.sh's relay, and clustering is marked experimental in the docs, so plan on one server. For watching the services that publish to it, Uptime Kuma is listed among ntfy's official integrations.

Questions

Is a fresh ntfy install open to the internet?

Yes. The docs state that by default anyone can read and write any topic, which is how ntfy.sh runs. Set auth-file and auth-default-access: deny-all, then grant access per user with ntfy access. Topic names are not secrets once the server is reachable.

Should I use the Debian package or the Docker image?

Both are official and run the same server. The package gives you a systemd unit, an ntfy user and the right folders, and upgrades with apt. The image suits a host already run with Compose; publish it on 127.0.0.1 and let nginx face the internet.

What does ntfy.sh receive when the iOS relay is on?

According to the docs, a poll request carrying the message ID in an X-Poll-ID header, sent to a topic named after the SHA-256 of your topic URL, with the placeholder text "New message". The phone then fetches the actual message from your own server and displays it; the placeholder shows only when your server cannot be reached in time. Leave upstream-base-url unset if nobody uses the iOS app.

How is ntfy licensed?

The README says the project is dual licensed under Apache License 2.0 and GPLv2. Our general reading, not legal advice: running it unmodified for your own notifications asks nothing of you. If you distribute ntfy, modified or not, you follow the terms of the licence you pick: Apache-2.0 means keeping the licence and notices and marking your changes, GPL-2.0 means offering the source of what you ship.

Can I run ntfy on the ARM cartridge?

ntfy publishes arm64 builds, but our X-Gene cartridge installs only Ubuntu 16.04 and 18.04. Use an x86 machine with a current Debian or Ubuntu release instead.

Give the alerts a machine of their own

Every dedicated line we run: root on the metal, a port nothing meters, terms by the month.

See the machines

Related on Server Room

Other use cases