Skip to content

How to Deploy ntfy on a VPS

Updated Sep 2026

verified on Ubuntu 26.04 · Sep 2026
We earn commissions when you shop through the links below. Full disclosure →

Self-host ntfy — push notifications to your phone from any script with a single curl — on a small VPS, locked down to deny-all with an admin user, persistent cache and HTTPS via Caddy.

Before you start
  • A small VPS — 1 vCPU / 512 MB–1 GB RAM is plenty
  • A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
  • A domain (or subdomain) you can point at the server
  • Docker Engine + Compose installed (see the base guide below)
Need a box for this guide? Kamatera's free tier lets you spin one up now.Start free on Kamatera → (opens in new tab)

What ntfy is

ntfy (pronounced "notify") is a pub/sub notification server. You publish a message with a plain HTTP PUT or POST to a topic — a URL like https://ntfy.example.com/backups — and every phone, browser tab or script subscribed to that topic receives it. There is an Android app, an iOS app and a web app, but the publishing side needs nothing more than curl:

curl -d "Nightly backup finished" https://ntfy.example.com/backups

That one-liner is the whole appeal. Cron jobs, CI pipelines, backup scripts and monitoring tools can all notify you without an SDK, an account or a third-party cloud in the middle.

The default is wide open, and that is the thing to change. Out of the box, ntfy lets anyone read and write any topic — the topic name is the password. That is how the public ntfy.sh service works, and it is fine for throwaway topics, but on your own server you almost certainly want the opposite: nobody gets in unless you created them a user. This guide sets auth-default-access: "deny-all" from the first start and creates one admin account.

If you are still choosing between push servers, Gotify vs ntfy covers the trade-offs.

Server sizing

ntfy is a single Go binary with SQLite databases next to it, and it is about as light as a self-hosted service gets:

  • Minimum: 256 MB RAM, per our catalog entry.
  • Measured: idle RAM of ~13 MB and ~115 MB of disk for the running container on our test box (Ubuntu 26.04, Docker).
  • In practice: the smallest plan from any provider is enough. A 1 GB VPS leaves room for Caddy and a few other small services on the same box.

Disk growth comes from two places: the message cache (small — messages are kept for 12 hours by default) and file attachments, if you enable them (capped by attachment-total-size-limit, 5 GB by default).

Like any alerting tool, ntfy is most useful on a box that is not the thing it tells you about. If your notifications come from the same VPS that just ran out of disk, you may not hear about it.

Prepare the server

This guide assumes Docker Engine and the Compose plugin are installed. If not, work through Docker & Compose on Ubuntu first.

Open SSH plus the ports the reverse proxy needs — ntfy's own port stays on loopback:

sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw --force enable
sudo ufw status verbose
Where to host itaffiliate disclosure
Hetzner Cloudrun it on
2 vCPU · 4 GB RAM · 80 GB SSD · $23.59/mo
Get Hetzner Cloud (opens in new tab)
Vultralso works on
1 vCPU · 1 GB RAM · 25 GB SSD · $5.00/mo
Get Vultr (opens in new tab)
DigitalOceanalso works on
1 vCPU · 1 GB RAM · 25 GB SSD · $6.00/mo
Deploy on DigitalOcean → (opens in new tab)

Paid link — we earn a commission if you shop through it.

Write server.yml

The ntfy Docker image does not ship a /etc/ntfy/server.yml; you create one on the host and mount the directory. Every option has an environment-variable twin (NTFY_BASE_URL, NTFY_AUTH_DEFAULT_ACCESS, …), but a file is easier to read, diff and back up, and the ntfy user / ntfy access CLI commands read the same file — so the auth database path only has to be written down once.

Create the project directory, with one folder per thing that must survive a container restart:

mkdir -p ~/ntfy/etc ~/ntfy/cache ~/ntfy/lib
cd ~/ntfy

Now the config. Replace ntfy.example.com with your own hostname:

cd ~/ntfy
cat > etc/server.yml <<'YAML'
# Public URL of this server — used for attachment links and the web app.
base-url: "https://ntfy.example.com"

# Inside the container ntfy listens on :80; compose maps it to loopback only.
listen-http: ":80"

# Caddy sits in front: take the visitor IP from X-Forwarded-For.
# Without this, every visitor is rate-limited as one.
behind-proxy: true

# Persistent message cache (without it, messages live only in memory).
cache-file: "/var/cache/ntfy/cache.db"

# File attachments (needs base-url to build download links).
attachment-cache-dir: "/var/cache/ntfy/attachments"

# Private instance: nobody can read or write unless granted.
auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all"

# Let users log in to the web app.
enable-login: true

# iOS only: uncomment so iPhones get instant notifications (see below).
# upstream-base-url: "https://ntfy.sh"
YAML

A few notes on those keys:

  • behind-proxy: true is not optional once Caddy is in front. ntfy rate-limits per visitor IP; behind a proxy every request appears to come from 127.0.0.1 unless you tell it to read the forwarded header.
  • cache-file keeps messages across restarts, so a phone that was offline catches up on what it missed (within cache-duration, default 12 hours).
  • auth-file is a SQLite database created automatically on first use.
  • upstream-base-url matters only for the iOS app. Apple restricts background work, so your server forwards a tiny poll_request (a message ID and a hash of the topic URL, not the message body) to ntfy.sh, which wakes the phone; the phone then fetches the real message from your server. Without it, iOS notifications still arrive, just late. Android and the web app don't need it.

Install ntfy with Docker Compose

This follows the upstream compose example — serve as the command, the health check against /v1/health, and init: true, which upstream says is needed when a health check is used — with the port bound to loopback and three bind mounts for the config, cache and user database:

cd ~/ntfy
cat > docker-compose.yml <<'YAML'
services:
  ntfy:
    image: binwiederhier/ntfy:latest
    container_name: ntfy
    command:
      - serve
    environment:
      - TZ=UTC
    volumes:
      - ./etc:/etc/ntfy
      - ./cache:/var/cache/ntfy
      - ./lib:/var/lib/ntfy
    ports:
      # Loopback only — Caddy is the sole route in from outside.
      - "127.0.0.1:2586:80"
    healthcheck:
      test: ["CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\"healthy\"\\s*:\\s*true' || exit 1"]
      interval: 60s
      timeout: 10s
      retries: 3
      start_period: 40s
    init: true
    restart: unless-stopped
YAML
docker compose up -d

Wait for it to answer on the loopback port:

for i in $(seq 1 30); do
  curl -fsS http://127.0.0.1:2586/v1/health && break
  sleep 2
done
docker compose -f ~/ntfy/docker-compose.yml ps

You should see {"healthy":true}.

Create an admin user

With deny-all in place, the server currently refuses everyone — including you. Create an admin; the admin role can read and write every topic, so no extra access rules are needed for it. ntfy user add normally prompts for the password, but it also accepts it from the NTFY_PASSWORD environment variable, which is what makes it scriptable. Generate a strong password and keep it in a file only you can read:

cd ~/ntfy
[ -f admin-password ] || openssl rand -base64 24 | tr -d '/+=' > admin-password
chmod 600 admin-password
docker compose exec -T -e NTFY_PASSWORD="$(cat admin-password)" ntfy \
  ntfy user add --role=admin --ignore-exists admin
docker compose exec -T ntfy ntfy access

--ignore-exists makes the command safe to re-run. The last line prints the access control list: your admin user and the deny-all default for everyone else. Print the password once with cat ~/ntfy/admin-password and put it in your password manager.

Test publish and subscribe

Three checks, all against the loopback port: an anonymous publish must be refused, an authenticated publish must succeed, and polling the topic must return the message.

cd ~/ntfy
PASS="$(cat admin-password)"

# Anonymous: expect 403 Forbidden.
code=$(curl -s -o /dev/null -w '%{http_code}' -d "should fail" http://127.0.0.1:2586/test-topic)
echo "anonymous publish: $code"
[ "$code" = "403" ]

# Authenticated publish, with a title.
curl -fsS -u "admin:$PASS" -H "Title: Hello" -d "ntfy is working" \
  http://127.0.0.1:2586/test-topic

# Poll the topic instead of holding a connection open.
curl -fsS -u "admin:$PASS" "http://127.0.0.1:2586/test-topic/json?poll=1"

The last command prints the message as a line of JSON. If the anonymous check prints 200, the deny-all setting didn't load — see Troubleshooting.

HTTPS and your domain with Caddy

Point an A record (and AAAA if you have IPv6) for ntfy.example.com at the server, then install Caddy by following Automatic HTTPS with Caddy. The site block is short — Caddy's reverse_proxy already handles the WebSocket and streaming connections ntfy subscribers use:

ntfy.example.com {
    reverse_proxy 127.0.0.1:2586
}

Reload Caddy and it will fetch a certificate on the first request. Then open https://ntfy.example.com, log in as admin and subscribe to test-topic in the web app. From your laptop:

curl -u "admin:YOUR_PASSWORD" -d "Hello over HTTPS" https://ntfy.example.com/test-topic

In the Android or iOS app, subscribe using your server's URL instead of ntfy.sh and sign in with the same user.

Securing it

  • Give scripts their own users. Don't hand your admin password to a cron job. Create a regular user and grant it only the topics it needs: ntfy user add backup-script, then ntfy access backup-script backups rw (run both via docker compose exec -T ntfy … as above).
  • Or use tokens. ntfy token add backup-script creates an access token you can send as a bearer token instead of a password, and revoke on its own.
  • Leave signup off. enable-signup defaults to false; keep it that way so the only accounts are the ones you create.
  • Keep the port on loopback. The 127.0.0.1:2586 binding plus ufw means the only public entry is Caddy on 443.

Backups

The state is three directories: etc/ (config), lib/ (users, access rules, tokens) and cache/ (messages and attachments). Only the first two are hard to recreate — cached messages expire within hours anyway. Stop the container briefly so the SQLite files are consistent:

cd ~/ntfy
mkdir -p backups
docker compose stop
sudo tar czf backups/ntfy-$(date +%F).tar.gz etc lib cache docker-compose.yml admin-password
docker compose start
ls -lh backups/

Copy the archive off the box. To restore, stop the stack, extract the archive over ~/ntfy, and start it again.

Upgrades

cd ~/ntfy
docker compose pull
docker compose up -d
docker compose ps

latest follows the newest release (v2.28.0 at the time of writing). If you prefer upgrades to be a deliberate step, pin a version tag such as binwiederhier/ntfy:v2.28.0 in the compose file and bump it after reading the release notes. Take a backup before any upgrade.

Troubleshooting

Anonymous publishes still succeed. The config file isn't being read. Check that it exists at ~/ntfy/etc/server.yml (not a directory of that name) and that the volume maps ./etc to /etc/ntfy. Upstream notes the image contains no config file of its own, so a missing mount silently falls back to the open defaults.

ntfy user add fails, or users vanish on restart. The CLI reads auth-file from /etc/ntfy/server.yml; if that key is missing, it has nowhere to write. Also make sure /var/lib/ntfy is mounted — otherwise the database lives inside the container and disappears when it is recreated.

Everyone gets rate-limited at once. behind-proxy: true is missing, so ntfy sees every request as coming from Caddy's address.

Attachments fail. Attachments need both attachment-cache-dir and base-url. A base-url still set to ntfy.example.com produces download links that point nowhere.

iPhone notifications arrive late. Set upstream-base-url: "https://ntfy.sh" and restart the container.

For anything else, read the recent logs:

cd ~/ntfy
docker compose logs -f ntfy

Verification + next steps

You're done when: https://ntfy.example.com loads over a valid certificate and asks you to log in, an anonymous curl publish gets 403, an authenticated one reaches your phone within seconds, and docker compose restart doesn't lose your users.

From there, wire ntfy into the things that should wake you up. Uptime Kuma and Gatus can alert to an ntfy topic, and Healthchecks covers the other half — noticing when a cron job didn't run. For the ranked host picks, see Best VPS for Monitoring & Uptime.

Next steps

How to self-host ntfy →Automatic HTTPS with Caddy →Run Claude Code with Ollama on Your Own VPS →Deploy Coolify on a VPS →How to Deploy Actual Budget on a VPS →How to Deploy AnythingLLM on a VPS →How to Deploy Appwrite on a VPS →How to Deploy Audiobookshelf on a VPS →How to Deploy Authelia on a VPS →How to Deploy authentik on a VPS →How to Deploy Baserow on a VPS →How to Deploy Beszel on a VPS →How to Deploy Bitwarden on a VPS →How to Deploy BookStack on a VPS →How to Deploy CapRover on a VPS →How to Deploy Checkmate on a VPS →How to Deploy Directus on a VPS →How to Deploy docker-mailserver on a VPS →How to Deploy Docmost on a VPS →How to Deploy Dokku on a VPS →How to Deploy Dokploy on a VPS →How to Deploy Firefly III on a VPS →How to Deploy Forgejo on a VPS →How to Deploy Gatus on a VPS →How to Deploy Ghostfolio on a VPS →How to Deploy Gitea on a VPS →How to Deploy GitLab on a VPS →How to Deploy GlitchTip on a VPS →How to Deploy Grafana on a VPS →How to Deploy Graylog on a VPS →How to Deploy Headscale on a VPS →How to Deploy Healthchecks on a VPS →How to Deploy Home Assistant on a VPS →How to Deploy Immich on a VPS →How to Deploy Jan on a VPS →How to Deploy Jellyfin on a VPS →How to Deploy Karakeep on a VPS →How to Deploy Keycloak on a VPS →How to Deploy Leantime on a VPS →How to Deploy LibreChat on a VPS →How to Deploy Linkwarden on a VPS →How to Deploy LocalAI on a VPS →How to Deploy Mailcow on a VPS →How to Deploy Mailu on a VPS →How to Deploy Matomo on a VPS →How to Deploy Mattermost on a VPS →How to Deploy Meilisearch on a VPS →How to Deploy Memos on a VPS →How to Deploy n8n on a VPS →How to Deploy Navidrome on a VPS →How to Deploy NetBird on a VPS →How to Deploy Netdata on a VPS →How to Deploy Nextcloud on a VPS →How to Deploy Next.js to a VPS →How to Deploy Nginx Proxy Manager on a VPS →How to Deploy NocoDB on a VPS →How to Deploy Ollama on a VPS →How to Deploy Open WebUI on a VPS →How to Deploy OpenHands on a VPS →How to Deploy OpenObserve on a VPS →How to Deploy OpenProject on a VPS →How to Deploy Outline on a VPS →How to Deploy Pangolin on a VPS →How to Deploy Paperless-ngx on a VPS →How to Deploy Passbolt on a VPS →How to Deploy Plane on a VPS →How to Deploy Plausible Analytics on a VPS →How to Deploy Pocket ID on a VPS →How to Deploy PocketBase on a VPS →How to Deploy Prometheus on a VPS →How to Deploy Psono on a VPS →How to Deploy Radarr on a VPS →How to Deploy Rocket.Chat on a VPS →How to Deploy SigNoz on a VPS →How to Deploy Sonarr on a VPS →How to Deploy Stalwart on a VPS →How to Deploy Stirling-PDF on a VPS →How to Deploy Supabase on a VPS →How to Deploy Synapse on a VPS →How to Deploy Taiga on a VPS →How to Deploy TeamPass on a VPS →How to Deploy Tinyauth on a VPS →How to Deploy Traefik on a VPS →How to Deploy Trilium on a VPS →How to Deploy Twenty CRM on a VPS →How to Deploy Umami on a VPS →How to Deploy Uptime Kuma on a VPS →How to Deploy Vaultwarden on a VPS →How to Deploy Vikunja on a VPS →How to Deploy wg-easy on a VPS →How to Deploy Wiki.js on a VPS →How to Deploy Zabbix on a VPS →How to Deploy Zitadel on a VPS →How to Deploy Zulip on a VPS →Docker & Compose on Ubuntu 26.04 →Building AI Workflows with n8n →Install Open WebUI with Ollama →Adding AI-Powered Insights to Plausible Analytics →Building AI-Powered Apps with Supabase and pgvector →

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.