Skip to content
Get Started

How to Self-Host ntfy with Docker

ntfy turns an HTTP request into a notification in a browser or mobile app. A useful private deployment needs more than a running container: it needs access control, TLS, correct proxy behavior, persistent authentication data, and a test from publisher to subscriber.

This guide builds that deployment on a Debian or Ubuntu VPS. The application listens only on 127.0.0.1:2586; Caddy or Nginx owns the public ports. We use the official binwiederhier/ntfy:v2.26.0 image, which was the latest release when checked on July 17, 2026, with ntfy’s SQLite storage, the conservative choice for one unmanaged server.

One warning before anything else: ntfy allows anonymous users to read and write every topic by default, and adding an auth-file alone does not make the server private. The configuration below sets auth-default-access: deny-all, then grants access with named users and topic ACLs.

Prerequisites: prepare the VPS, DNS, and firewall

You need a Linux VPS with root or sudo access, a DNS record for ntfy.example.com, and Docker Engine with the Compose plugin. Point an A record at the server’s IPv4 address. Add an AAAA record only if the VPS, firewall, and reverse proxy all serve IPv6.

Our VPS plans are unmanaged, so you run Docker, TLS, the firewall, monitoring, backups, and upgrades yourself and keep full control of the stack. If you want one server for several small services, our self-hosted apps guide can help you decide what belongs on the same VPS.

Install Docker Engine and the Compose plugin. Docker’s convenience script is the quickest way to get both; anyone uneasy about running a downloaded script as root can install from the distribution’s packages instead:

curl -fsSL https://get.docker.com | sudo bash
sudo docker compose version

The rest of the guide assumes your user can run Docker without sudo. Either prefix Docker commands with sudo, or follow Docker’s documented post-install procedure.

Allow SSH before enabling or changing a firewall; if you have not hardened that port yet, our walkthrough of securing SSH on a VPS covers it. If UFW is already active, add only the web ports:

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw status

Mirror those rules in any provider firewall. Do not open port 2586 publicly. After DNS propagates, getent ahosts ntfy.example.com should show this VPS.

Create the ntfy Compose project

Keep the project in one directory so its configuration and storage paths remain unambiguous:

sudo install -d -m 0750 -o "$USER" -g "$USER" /opt/ntfy
cd /opt/ntfy
umask 077
install -d -m 0700 cache data

Create /opt/ntfy/compose.yaml. The loopback binding, deny-by-default policy, explicit limits, and file permissions are hardening choices on top of ntfy’s official example and configuration reference.

services:
  ntfy:
    image: binwiederhier/ntfy:v2.26.0
    container_name: ntfy
    command: serve
    environment:
      TZ: UTC
    ports:
      - "127.0.0.1:2586:80"
    volumes:
      - ./server.yml:/etc/ntfy/server.yml:ro
      - ./cache:/var/cache/ntfy
      - ./data:/var/lib/ntfy
    healthcheck:
      test:
        [
          "CMD-SHELL",
          "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\"healthy\"\\s*:\\s*true' || exit 1",
        ]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 20s
    init: true
    restart: unless-stopped

Create /opt/ntfy/server.yml:

base-url: "https://ntfy.example.com"
behind-proxy: true

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

attachment-cache-dir: "/var/cache/ntfy/attachments"
attachment-total-size-limit: "5G"
attachment-file-size-limit: "15M"
attachment-expiry-duration: "3h"
visitor-attachment-total-size-limit: "100M"
visitor-attachment-daily-bandwidth-limit: "500M"

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

enable-login: true
require-login: true
enable-signup: false

# Optional for prompt delivery to the official iOS app:
# upstream-base-url: "https://ntfy.sh"

Keep the project files private too:

cd /opt/ntfy
chmod 600 compose.yaml server.yml

The 12-hour message retention and the attachment limits are ntfy’s documented defaults, written out here so you can review them. They are limits, not a sizing recommendation. cache/cache.db and cache/attachments/ hold temporary messages and files. data/user.db is durable security state: users, password hashes, tokens, and ACLs.

Start ntfy and check its health:

docker compose up -d --wait --wait-timeout 120
curl -fsS http://127.0.0.1:2586/v1/health

The response should contain {"healthy":true}.

Create users and topic ACLs

There is no default administrator. Add one interactively; the password is hashed into data/user.db rather than written into Compose:

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

An administrator can read and write every topic. For automation, a regular account with a narrow ACL is safer. These commands create a publisher that may write, but not read, the backups topic:

docker compose exec ntfy ntfy user add publisher
docker compose exec ntfy ntfy access publisher backups write-only

Create a separate subscriber if a person or service needs read-only access:

docker compose exec ntfy ntfy user add subscriber
docker compose exec ntfy ntfy access subscriber backups read-only
docker compose exec ntfy ntfy access

You can create a client token so scripts never see the account password:

docker compose exec ntfy ntfy token add --expires=90d --label="tutorial-client" publisher

Store the returned token in a secret manager. ntfy tokens inherit the user’s permissions; they have no separate scopes.

Put ntfy behind HTTPS

TLS is required because Basic credentials and bearer tokens must never cross plaintext HTTP. behind-proxy: true also matters: without it, ntfy sees the proxy as every visitor and applies one shared rate-limit bucket.

Caddy’s automatic HTTPS keeps the proxy configuration small. Install Caddy through its official distro instructions, then add this site to /etc/caddy/Caddyfile:

ntfy.example.com {
    reverse_proxy 127.0.0.1:2586
}

Validate and reload it:

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

Caddy forwards client information and supports WebSocket upgrades by default. For Nginx, use ntfy’s official reverse-proxy example, not a bare proxy_pass: preserve Host, append X-Forwarded-For, set X-Forwarded-Proto, use HTTP/1.1 with Upgrade and Connection, disable response buffering for streaming subscriptions, allow request bodies up to your attachment limit, and set long read and send timeouts. Terminate TLS with a valid certificate on port 443 and redirect port 80 to HTTPS.

Verify the public endpoint:

curl -fsS https://ntfy.example.com/v1/health

Run an end-to-end notification test

First prove that anonymous publishing is blocked:

curl -sS -o /dev/null -w '%{http_code}\n' \
  -d 'anonymous publish should fail' \
  https://ntfy.example.com/backups

The result must not be a 2xx status. A 401 or 403 confirms the private default is in effect.

In the ntfy web app or mobile app, add https://ntfy.example.com as the server, sign in as subscriber, and subscribe to backups. Then publish with the restricted account:

curl -fsS \
  -u 'publisher:YOUR_PASSWORD' \
  -H 'Title: Backup test' \
  -H 'Priority: high' \
  -d 'ntfy end-to-end test' \
  https://ntfy.example.com/backups

Confirm the message appears in the subscribed browser or phone. Polling the API gives a second check:

curl -fsS \
  -u 'subscriber:YOUR_PASSWORD' \
  'https://ntfy.example.com/backups/json?poll=1&since=10m'

Now test persistence within the 12-hour cache window:

docker compose restart ntfy
curl -fsS https://ntfy.example.com/v1/health
curl -fsS -u 'subscriber:YOUR_PASSWORD' \
  'https://ntfy.example.com/backups/json?poll=1&since=10m'

For an attachment test, create a harmless file smaller than 15 MB and upload it:

printf 'attachment persistence test\n' > /tmp/ntfy-test.txt
curl -fsS -u 'publisher:YOUR_PASSWORD' \
  -H 'Filename: ntfy-test.txt' \
  -T /tmp/ntfy-test.txt \
  https://ntfy.example.com/backups
rm /tmp/ntfy-test.txt

The subscriber should receive an attachment link hosted at ntfy.example.com. Treat that URL as a capability link: ntfy’s file handler serves the attachment by message ID, without applying the topic ACL to the download. Anyone who obtains the URL can fetch the file until it expires. Do not send sensitive attachments through this path; use an authenticated object-delivery system when each download must be authorized.

For a harmless test file, download it before the configured three-hour expiry, restart ntfy, and confirm it remains available during that window. ntfy manages the attachment directory and deletes expired or unreferenced files, so never share the directory with another application.

Understand the iOS delivery trade-off

Prompt delivery to the official iOS app requires an APNS-connected upstream, normally https://ntfy.sh. Uncomment upstream-base-url and recreate ntfy if that delivery behavior matters. According to ntfy’s iOS documentation, the upstream receives the message ID and a SHA-256-derived topic identifier, not the message body.

Leaving the setting disabled avoids that upstream dependency, but iOS notifications can be delayed significantly. A self-hosted ntfy server therefore does not promise fully independent, instant iOS push. Android normally keeps an instant-delivery connection to the self-hosted server; using FCM requires a custom app build and Firebase configuration.

Back up and restore ntfy

ntfy has no dedicated backup tool. Stop the container, archive the project directory, and start it again:

cd /opt/ntfy
install -d -m 0700 backups
archive="backups/ntfy-$(date +%F-%H%M%S).tar.gz"

docker compose stop ntfy
sudo tar -czf "$archive" compose.yaml server.yml data cache
sudo chmod 600 "$archive"
docker compose start ntfy
curl -fsS --retry 12 --retry-delay 5 --retry-all-errors \
  http://127.0.0.1:2586/v1/health

data/user.db is the part you cannot lose: it holds accounts, tokens, and ACLs. The message cache and attachments are temporary by design; a minimal backup may omit cache, but document that recovery will then not include recent messages or files.

Encrypt the archive before transferring it off the VPS. File permissions protect it on this host, not at the destination.

To restore, stop ntfy and move the damaged project aside. This example assumes the backup archive is still under the old project’s backups directory; list that directory first and replace the ntfy-YYYY-MM-DD-HHMMSS.tar.gz placeholder below with the real filename, which carries both the date and the time of the backup:

cd /opt/ntfy
docker compose stop ntfy
cd /opt
sudo mv ntfy ntfy.failed
sudo install -d -m 0750 -o "$USER" -g "$USER" /opt/ntfy
cd /opt/ntfy
ls /opt/ntfy.failed/backups/
sudo tar -xzf /opt/ntfy.failed/backups/ntfy-YYYY-MM-DD-HHMMSS.tar.gz
sudo chown -R "$USER":"$USER" /opt/ntfy
install -d -m 0700 backups
chmod 700 data cache

docker compose up -d --wait --wait-timeout 120
curl -fsS https://ntfy.example.com/v1/health
docker compose exec ntfy ntfy access

Start the same ntfy version first. Verify an authenticated publish and a cached attachment before upgrading. Do not run docker compose down -v; it is unnecessary here and a dangerous habit around stateful Compose projects.

Every one of our plans includes daily backups, a solid fallback when the entire host needs to come back. Keep the stopped archive, the rehearsed restore, and an encrypted off-server copy in your routine as well; they are what guarantee an application-consistent data/user.db.

Upgrade without losing state

Read the ntfy release notes, take a fresh backup, and change the image tag in compose.yaml only after reviewing the target release. Then run:

docker compose pull ntfy
docker compose up -d --wait --wait-timeout 120
curl -fsS https://ntfy.example.com/v1/health

Repeat the anonymous rejection and authenticated delivery tests. Keep the old archive until the new version has survived a restart and delivered an attachment.

Choosing a Riven Cloud VPS for ntfy

ntfy itself is a light workload. For a private, authenticated server handling alerts from a handful of machines, our Premium plan (2 vCPU, 4 GB RAM, 40 GB NVMe, 1 TB monthly transfer) is more than enough. Consider Ultra (2 vCPU, 8 GB RAM, 80 GB NVMe, 2 TB transfer) or Max (4 vCPU, 16 GB RAM, 160 GB NVMe, 4 TB transfer) if you expect many subscribers, long retention, or frequent large attachments. Whichever plan you pick, keep the ACLs and quotas above in place; access control is what keeps transfer and disk usage predictable.

Choose Tokyo or Singapore based on where your publishers and subscribers sit. If clients connect from mainland China, a quick test from the Tokyo Looking Glass and Singapore Looking Glass on your own carrier, at the time of day you care about, will tell you more than any route description. The Riven Cloud VPS plan comparison shows the current NVMe and monthly-transfer limits.

Security limits and when not to self-host

Publicly writable ntfy topics can become notification spam, phishing, attachment hosting, or unexpected VPS transfer. ntfy includes request, subscription, and attachment limits, but those controls do not turn an anonymous service into a private one. Keep deny-all, grant narrow ACLs, protect tokens, monitor logs and disk usage, and patch both Docker and ntfy.

Hosted ntfy or another managed notification service is the better fit when nobody has time to maintain TLS, monitoring, updates, and restore tests; when you need contractual availability or delivery guarantees; or when public anonymous topics are part of the product and running an abuse desk is not realistic. And if dependable iOS push is the hard requirement, be clear-eyed about the trade. With upstream-base-url set, every notification sends a delivery poke through ntfy.sh, though the upstream sees only the message ID and a hashed topic identifier while the content stays on your server. That is a dependency on upstream infrastructure rather than a content leak, but it does mean your alerting is only as available as ntfy.sh; if that dependency is unacceptable and instant iOS delivery is non-negotiable, hosted delivery is the more practical choice.

Share