Ntfy — Self-Hosted Push Notifications

I’ve been looking for a clean way to send push notifications from my home lab for a while. Things like backup completion alerts, Uptime Kuma webhook targets, or just a quick curl call from a script to let me know something finished running. I tried a few hosted options but they either had annoying limits on the free tier or required an account tied to some third-party service I’d rather not depend on.

Ntfy solves this cleanly. It’s an open source, self-hosted notification service that uses a simple HTTP-based publish/subscribe model. You publish a message to a topic via a plain HTTP POST, and anyone subscribed to that topic receives a push notification — on their phone, browser, or anywhere else the Ntfy app is installed. No accounts with third parties, no vendor lock-in, and the setup is about as straightforward as it gets.

This post covers deploying Ntfy with Docker, locking it down with authentication, managing users and their topic access, and setting up API tokens so your scripts and applications can publish without using your account password.


Prerequisites


Project Structure

Ntfy needs two things bind-mounted from the host: a config file and a data directory for the user database, message cache, and attachments. Create this structure before deploying:

ntfy/
├── server.yml
└── data/

The data/ directory can be created empty — Ntfy will initialize the database files on first start. The server.yml file is what we’ll configure next.

Heads up if you’re running as a non-root user: Create the data/ directory manually as the user you plan to run the stack under before deploying. If Docker creates it on first run, it will be owned by root, and the container will fail to write to it. A quick mkdir data while logged in as (or sudo -u) your service account is enough to avoid this.


Server Configuration

Create server.yml inside your ntfy directory with the following contents:

base-url: "https://ntfy.example.com"
listen-http: ":80"

auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all"
enable-login: true

cache-file: "/var/lib/ntfy/cache.db"
cache-duration: "12h"

attachment-cache-dir: "/var/lib/ntfy/attachments"
attachment-total-size: "5G"
attachment-file-size: "15M"
attachment-expiry-duration: "3h"

A few things worth pointing out here:

  • base-url — set this to the URL you’ll be accessing Ntfy from. If you’re using a reverse proxy with a custom domain, put that here. This is used to generate click-through links in notifications.
  • auth-file — this is the SQLite database Ntfy uses to store users and their access permissions. The path must be inside the Docker volume so it persists across container restarts.
  • auth-default-accessdeny-all means no one can publish or subscribe to any topic unless explicitly granted access. This is the right setting for a private instance. If you want a fully open instance (anyone can publish and subscribe), set this to read-write, but that’s not recommended if your instance is reachable from the internet.
  • enable-login — this must be explicitly set to true for the web UI login form to work. Even with auth-file configured and users created, Ntfy defaults this to false, which leaves the web UI showing “Login is disabled.”
  • The cache block controls how long undelivered messages are held so devices that were offline can catch up.
  • The attachment block enables file attachments in notifications and sets limits on attachment size and total storage used.

Deploying the Stack

In Dockhand, go to Stacks, click Add stack, give it a name like ntfy, and paste the following into the editor:

services:
  ntfy:
    image: binwiederhier/ntfy:latest
    container_name: ntfy
    user: "${PUID}:${PGID}"
    ports:
      - "${NTFY_PORT}:80"
    volumes:
      - /home/${NTFY_USERNAME}/ntfy/server.yml:/etc/ntfy/server.yml
      - /home/${NTFY_USERNAME}/ntfy/data:/var/lib/ntfy
    environment:
      - TZ=${TZ}
    command: serve
    healthcheck:
      test: ["CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -o '\"healthy\":true' || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s
    deploy:
      resources:
        limits:
          memory: 512M
    restart: unless-stopped

The compose file uses environment variables to handle volume paths dynamically — no hardcoded paths needed. It also runs the container under a specific user via PUID/PGID, includes a healthcheck that polls the /v1/health endpoint every 30 seconds, and caps memory at 512MB.

Add the following environment variables in the Dockhand stack editor:

Name Example Value Description
PUID 1000 UID of the user to run the container as
PGID 1000 GID of the user to run the container as
NTFY_USERNAME dockersa Username whose home directory holds the ntfy config and data
NTFY_PORT 8080 Host port to expose Ntfy on
TZ America/Los_Angeles Timezone for the container

To find your PUID and PGID, run id on your server while logged in as the user you want the container to run as.

Click Deploy the stack. Once Dockhand shows all containers as running, Ntfy is up and listening on the port you configured.

Deploying via GitOps Integration

If you’d rather have Dockhand pull and deploy the compose file directly from the repository, you can use the Git Integration feature instead of pasting manually. Go to Settings → Git Integration, connect your repository, and Dockhand will watch for changes and redeploy affected stacks automatically via webhook.

For a starting point, the compose file for this stack is in the CHNS/Docker-Compose repo at Ntfy/docker-compose-bind.yaml. See the Dockhand article for full setup details on the Git integration.

Dockhand showing Ntfy container running

Creating the Admin User

With auth-default-access: deny-all set, the first thing you need to do is create an admin user. This user can manage other users and has full read/write access to all topics.

Open the Ntfy container’s console in Dockhand (Containers → ntfy → Console), or run the command via docker exec:

docker exec -it ntfy ntfy user add --role=admin admin

Breaking down each part:

  • docker exec — runs a command inside a running container
  • -it-i keeps stdin open so you can type the password; -t allocates a pseudo-TTY so the prompt displays correctly
  • ntfy — the name of the container to run the command in
  • ntfy user add — the Ntfy CLI subcommand to create a new user
  • --role=admin — assigns the admin role, which grants full read/write access to all topics and the ability to manage other users
  • admin — the username to create; you can change this to whatever you want

You’ll be prompted to set a password. Once created, you can log into the Ntfy web UI at http://{your server IP}:8080 with these credentials.

Ntfy web UI login screen

Managing Users

Adding a Regular User

For each person or service that needs access to Ntfy, create a dedicated user account:

docker exec -it ntfy ntfy user add {username}

You’ll be prompted to set a password. Regular users have no topic access by default — you need to explicitly grant it.

Listing Users

docker exec -it ntfy ntfy user list

This shows all users and their assigned roles.

Changing a Password

docker exec -it ntfy ntfy user change-pass {username}

Removing a User

docker exec -it ntfy ntfy user del {username}

Granting Topic Access

Topic access is managed separately from user accounts. After creating a user, you grant them access to specific topics using the ntfy access command.

Grant Read/Write Access to a Topic

docker exec -it ntfy ntfy access {username} my-topic read-write

This allows username to both publish messages to my-topic and subscribe to receive them.

Grant Read-Only Access

Useful for users who should be able to receive notifications from a topic but not publish to it:

docker exec -it ntfy ntfy access {username} my-topic read-only

Grant Write-Only Access

Useful for services or scripts that need to publish to a topic but shouldn’t be able to read from it:

docker exec -it ntfy ntfy access {username} my-topic write-only

List Access Rules for a User

docker exec -it ntfy ntfy access {username}

Revoke Access

docker exec -it ntfy ntfy access --reset {username} my-topic

Or to revoke all access for a user at once:

docker exec -it ntfy ntfy access --reset {username}

Topic names in Ntfy are just strings — there’s no need to pre-create them. The first time a message is published to a topic, it’s automatically created. Access rules use exact topic names, but you can also use wildcards. For example, ntfy access username homelab-* read-write would grant access to any topic starting with homelab-.


API Tokens

Passwords work for interactive use, but for scripts and applications publishing notifications you’ll want API tokens instead. Tokens can be scoped and revoked individually without touching the user’s password.

Creating a Token

Tokens can be created from the Ntfy web UI or the CLI.

Via the web UI:

Log in as the user you want to create the token for, then go to Account → Access Tokens → Create token. Give it a description (e.g. uptime-kuma or backup-script) so you can identify it later.

Ntfy Access Tokens page in the web UI

Via the CLI:

docker exec -it ntfy ntfy token add {username}

In both cases you’ll get back a token that starts with tk_. Copy it immediately — it won’t be shown again.

Using a Token to Publish

Tokens are passed as a Bearer token in the Authorization header:

curl \
  -H "Authorization: Bearer {token}" \
  -H "Title: Backup Complete" \
  -H "Priority: default" \
  -d "Full backup completed successfully" \
  https://ntfy.example.com/my-topic

If you’re testing locally before setting up a reverse proxy, replace the domain with your server IP and port:

curl \
  -H "Authorization: Bearer {token}" \
  -d "Test notification" \
  http://192.168.1.10:8080/my-topic

Listing and Revoking Tokens

To list all tokens for a user:

docker exec -it ntfy ntfy token list {username}

To revoke a token:

docker exec -it ntfy ntfy token del {username} {token}

Each script or application that publishes to Ntfy should have its own token. That way if a token is ever compromised or a service is decommissioned, you can revoke just that token without affecting anything else.


Subscribing on Mobile

Install the Ntfy app on your phone (available on Android via F-Droid or Google Play, and iOS via the App Store). By default it points at the public ntfy.sh server — you’ll need to point it at your own instance.

In the app, go to Settings → Default server and enter your Ntfy URL. If your instance requires authentication, you’ll also need to enter your username and password or configure the app to use a token.

From there, tap the + button, enter a topic name, and you’re subscribed. Any messages published to that topic will now push to your device.

Ntfy mobile app subscribed to a topic

Wrapping Up

Ntfy has quickly become one of those tools I don’t think about anymore — it just works. I have it integrated with Uptime Kuma for downtime alerts, a few backup scripts that post on completion, and a couple of cron jobs that I’d otherwise have no visibility into. One curl call and the notification is on my phone in seconds.

The access control model is what makes it practical to run as a shared instance across a home lab. Each user or service gets their own account and token, scoped to exactly the topics they need. If a script’s token is ever rotated or revoked, nothing else is affected.

If you’re looking to expand from here, the Ntfy documentation has solid coverage of more advanced features like actions (buttons in notifications), notification scheduling, and email publishing.


References