How to Deploy ntfy on a VPS
Updated Sep 2026
verified on Ubuntu 26.04 · Sep 2026Self-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.
- 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)
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
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: trueis not optional once Caddy is in front. ntfy rate-limits per visitor IP; behind a proxy every request appears to come from127.0.0.1unless you tell it to read the forwarded header.cache-filekeeps messages across restarts, so a phone that was offline catches up on what it missed (withincache-duration, default 12 hours).auth-fileis a SQLite database created automatically on first use.upstream-base-urlmatters only for the iOS app. Apple restricts background work, so your server forwards a tinypoll_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, thenntfy access backup-script backups rw(run both viadocker compose exec -T ntfy …as above). - Or use tokens.
ntfy token add backup-scriptcreates an access token you can send as a bearer token instead of a password, and revoke on its own. - Leave signup off.
enable-signupdefaults tofalse; keep it that way so the only accounts are the ones you create. - Keep the port on loopback. The
127.0.0.1:2586binding plusufwmeans 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.