GICT knowledge base
How to Self-Host a Notification Server with ntfy
Published
Scripts can tell you that a job finished without sending email. ntfy accepts an HTTP request on a named topic and delivers the message to its subscribers. Running your own instance keeps the notification service under your control, but a topic name alone is not a password.
This guide installs ntfy with Docker, denies access by default, and gives separate accounts permission to publish and read one topic. You will receive a live notification, then check that unauthorized requests fail. The setup uses an SSH tunnel for encrypted, private access rather than a public website.
Before you start
Use an Ubuntu 24.04 VPS with a sudo-capable account, SSH access, and room for Docker and its images. The commands assume a fresh server, not an existing ntfy installation. You also need an SSH client and curl on your computer. Replace SERVER_IP and the SSH username ubuntu with your server’s details. The $ marker denotes a terminal prompt; do not copy it.
This is a private browser-and-API installation. Each remote computer needs its own SSH tunnel while using ntfy. If you need unattended mobile notifications or access from arbitrary devices, use a public HTTPS deployment instead; that requires additional setup. Browser receipt here does not establish Android or iOS background delivery. Self-hosted iOS instant delivery also involves an upstream notification service, as explained in the ntfy configuration documentation.
For occasional non-sensitive alerts, a hosted notification service may be less work. Self-hosting makes sense when controlling the server and its access rules matters enough to maintain it. This guide does not configure external push relays, email, or background Web Push.
Install Docker and prepare the files
Run these commands on the VPS:
$ sudo apt-get update
$ sudo apt-get install -y docker.io docker-compose-v2 curl
$ sudo systemctl enable --now docker
$ sudo docker compose version
$ mkdir -p ~/ntfy/config ~/ntfy/data
$ chmod 700 ~/ntfy ~/ntfy/data
$ cd ~/ntfy
The data directory will contain the user database and cached messages. Keep it private. This Compose setup uses the image’s default root user inside the container; do not add a different user: setting without adjusting directory ownership.
Create ~/ntfy/compose.yaml with these contents:
services:
ntfy:
image: binwiederhier/ntfy:v2.28.0
command: serve
restart: unless-stopped
ports:
- "127.0.0.1:8080:80"
volumes:
- ./config:/etc/ntfy:ro
- ./data:/var/lib/ntfy
The host-side address 127.0.0.1 is deliberate. It keeps port 8080 off the public interfaces. Do not replace it with 8080:80, which normally publishes on all host interfaces.
Create ~/ntfy/config/server.yml with these contents:
base-url: "http://localhost:18080"
listen-http: ":80"
cache-file: "/var/lib/ntfy/cache.db"
cache-duration: "24h"
auth-file: "/var/lib/ntfy/auth.db"
auth-default-access: "deny-all"
enable-login: true
enable-signup: false
upstream-base-url: ""
auth-file enables the authentication database. deny-all is what makes topics private by default. enable-login adds the web login interface; it does not replace the access rules. Cached messages expire after 24 hours, so this is not a permanent notification archive. The empty upstream setting leaves the iOS relay unconfigured.
Validate and start the service from ~/ntfy:
$ sudo docker compose config --quiet
$ sudo docker compose pull
$ sudo docker compose up -d
$ sudo docker compose ps
$ curl --fail --silent --show-error http://127.0.0.1:8080/v1/health
The recorded health response was:
{"healthy":true}
The container should be running. A health check only proves the server responds; the remaining steps test notifications and permissions.
Create the accounts and topic permissions
On the VPS, create two regular users. Each command asks for a password and confirmation without displaying the password:
$ sudo docker compose exec ntfy ntfy user add publisher
$ sudo docker compose exec ntfy ntfy user add reader
$ sudo docker compose exec ntfy ntfy access publisher server-alerts write-only
$ sudo docker compose exec ntfy ntfy access reader server-alerts read-only
$ sudo docker compose exec ntfy ntfy access
Choose different passwords and save them in your password manager. The access listing should give publisher write-only access and reader read-only access to server-alerts. Other topics remain denied. There is no separate topic-creation step: publishing and subscribing use the topic name in the URL.
Do not use --role=admin for these accounts. An administrator can read and write all topics, which defeats the separation being tested here.
Connect through SSH
On your computer, open a terminal and leave this command running:
$ ssh -N -o ExitOnForwardFailure=yes -L 127.0.0.1:18080:127.0.0.1:8080 ubuntu@SERVER_IP
The first loopback address belongs to your computer; the second belongs to the VPS. SSH encrypts traffic between them. Use http://localhost:18080 in the browser and the commands below, not http://SERVER_IP:8080.
The tunnel normally stays quiet. Closing that terminal stops remote access, but does not stop ntfy. Do not disable SSH host-key checks if a connection reports an unexpected server identity.
Receive a notification
Open http://localhost:18080 in your browser. Click Sign in, log in as reader, then choose Subscribe to topic. Enter server-alerts and click Subscribe. Leave Use another server unchecked. Keep the tab open. Browser permission to show desktop notifications is separate from displaying messages inside the web app; the in-app message is sufficient for this test.
In a second terminal on your computer, publish a message:
$ curl --fail-with-body --user publisher -H "Title: Test notification" -d "The notification server is working." http://localhost:18080/server-alerts
curl prompts for the publishing account’s password. Supplying only the username avoids putting the password in shell history. The response contains the published message and its ID. Confirm that the open reader tab displays Test notification and the same message body.
You can also read cached messages through the API:
$ curl --fail-with-body --user reader "http://localhost:18080/server-alerts/json?poll=1&since=all"
Enter the reader password. Find the same message ID in the returned JSON. This poll reads cached history and exits; it is different from the browser’s live subscription. Both should work.
Verify that access is restricted
On your computer, request the topic without credentials:
$ curl -s -o /dev/null -w '%{http_code}\n' "http://localhost:18080/server-alerts/json?poll=1"
$ curl -s -o /dev/null -w '%{http_code}\n' -d "This must not publish" http://localhost:18080/server-alerts
Each command returned this status in the test:
403
Both requests must be rejected, not return a successful 2xx status. Next, try publishing with the read-only account and reading with the write-only account:
$ curl --user reader -s -o /dev/null -w '%{http_code}\n' -d "This must not publish" http://localhost:18080/server-alerts
$ curl --user publisher -s -o /dev/null -w '%{http_code}\n' "http://localhost:18080/server-alerts/json?poll=1"
These requests also returned 403. Confirm that neither rejected message appears in the reader tab. Finally, repeat the permitted publish command with a different message to ensure the service still delivers authorized notifications.
If something does not work
- Connection refused: confirm the tunnel is still running. On the VPS, use
sudo docker compose psfrom~/ntfyand check the health endpoint again. If your computer already uses port 18080, free that local port before following these commands. - Login works but the topic is denied: check the exact username, topic spelling, and
ntfy accessoutput. A regular account has no topic access until you grant it. Do not fix this by making everyone an administrator. - Publish succeeds but the tab stays empty: confirm you are subscribed to
server-alertson your own localhost server, not ntfy.sh, and logged in asreader. A publisher response alone is not proof that a subscriber received it. - Database permission errors: keep both bind mounts and the data directory from this setup. Inspect container logs with the command below. Do not make the user database world-writable.
On the VPS, from ~/ntfy:
$ sudo docker compose logs --tail=50 ntfy
The result is a private notification server with a writable topic for the publisher and a readable topic for the subscriber. Keep using separate accounts when connecting scripts and readers; the 1.28-second browser receipt and rejected requests together confirmed this installation. That single test is not a delivery-time guarantee.
Related guides
How to Self-Host DocuSeal for Document Signing
Self-host DocuSeal with private administrator setup, PostgreSQL and HTTPS. Test signing, understand email data flows and create consistent backups.
Self HostedHow to Self-Host Docmost with Docker on Ubuntu 24.04
Install Docmost with Docker, PostgreSQL and Redis. Set up a private workspace and verify editing, live updates and attachment retrieval.
Self HostedHow to Remove Image Backgrounds Locally with rembg
Remove image backgrounds locally with rembg. Preserve originals, check PNG transparency and inspect cutout quality on both light and dark backgrounds.
Self HostedHow to Translate Text Offline with Argos Translate
Translate plain text locally with Argos Translate. Install a language pair, prepare caches, verify offline operation and review translation quality.
