Innovate | Connect | Transform
ContactService statusUSD $

GICT knowledge base

How to Self-Host a Notification Server with ntfy

Published

Self Hosted
How to Self-Host a Notification Server with ntfy

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 ps from ~/ntfy and 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 access output. 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-alerts on your own localhost server, not ntfy.sh, and logged in as reader. 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

Account services are unavailable until the production backend is configured.