If Gotify is the neat self-hosted notification dashboard, ntfy is the thing you reach for when you want notifications to be as boring as curl.
Publish to a topic URL. Subscribe from a phone, browser, desktop, CLI, or another script. That is the core idea.
That makes ntfy especially useful for backups, cron jobs, monitoring checks, Home Assistant automations, CI scripts, Docker update alerts, and small homelab tasks where adding a heavier workflow tool would be overkill.
What is ntfy?
ntfy is an open-source HTTP-based pub-sub notification service.
The project description is direct:
Send push notifications to your phone or desktop using PUT/POST
The public service runs at ntfy.sh , but the interesting part for self-hosters is that the same server can run locally with Docker.
The repository includes:
- a Go server
- a CLI for publishing and subscribing
- a React web app
- Android and iOS clients
- SQLite and PostgreSQL-backed persistence options
- Web Push, SMTP, Matrix Push Gateway, Twilio, Firebase/APNS, and Prometheus support
For a first homelab deployment, you do not need most of that. A single container with a persistent cache is enough to start.
Why Self-Host ntfy?
The biggest reason is control over your notification path.
With the public ntfy.sh service, topic names are effectively shared secrets. That is convenient, but you are still sending messages through someone else’s server.
With self-hosted ntfy, you decide where the messages are stored, how long they are cached, whether accounts are enabled, whether topics require auth, and how the instance is exposed.
It also makes local-only alerting easy. A NAS, Raspberry Pi, mini PC, or monitoring box can publish to http://ntfy.local/topic-name without needing a SaaS notification account.
Self-Hosting ntfy with Docker
This compose file pins ntfy to v2.28.0, publishes the web UI and API on port 8099, and keeps the message cache in a Docker volume.
name: ntfy
services:
ntfy:
image: binwiederhier/ntfy:v2.28.0
container_name: ntfy
command:
- serve
- --cache-file
- /var/cache/ntfy/cache.db
environment:
TZ: ${TZ:-Europe/Madrid}
ports:
- "${NTFY_PORT:-8099}:80"
volumes:
- ntfy_cache:/var/cache/ntfy
healthcheck:
test: ["CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\"healthy\"[[:space:]]*:[[:space:]]*true' || exit 1"]
interval: 60s
timeout: 10s
retries: 3
start_period: 20s
restart: unless-stopped
volumes:
ntfy_cache:
The matching Home-Lab config is here:
Home-Lab ntfy composeStart it with:
cd /home/jalcocert/Desktop/Home-Lab/ntfy
cp .env.sample .env
docker compose --env-file .env up -d
Then check the server:
curl -s http://localhost:8099/v1/health
Expected response:
{"healthy":true}
The web UI is available at:
http://localhost:8099
Sending a Test Notification
The simplest ntfy publish command is just a POST body to a topic:
curl -d "Backup finished" http://localhost:8099/my-homelab-topic
For a slightly richer message:
curl \
-H "Title: Backup completed" \
-H "Priority: default" \
-H "Tags: white_check_mark" \
-d "The nightly archive finished successfully." \
http://localhost:8099/my-homelab-topic
To verify from the command line without a phone app, poll the topic:
curl -s "http://localhost:8099/my-homelab-topic/json?poll=1"
Field Note: Local Docker Test
I tested the Home-Lab compose locally on port 8099.
The container started with binwiederhier/ntfy:v2.28.0, /v1/health returned {"healthy":true}, and this publish-and-poll flow worked:
topic="foss-ntfy-test-$(date +%s)"
curl -s \
-H "Title: Foss Engineer ntfy test" \
-H "Priority: default" \
-d "local ntfy compose works" \
"http://localhost:8099/$topic"
curl -s "http://localhost:8099/$topic/json?poll=1"
The poll endpoint returned the same message, title, topic, and priority.
Reverse Proxy Notes
For a private LAN-only notifier, localhost:8099 or an internal DNS name may be enough.
For a public instance, do not just throw it onto the internet and hope topic names are enough.
At minimum:
- put ntfy behind HTTPS
- set a public
base-url - set
behind-proxy: trueif a reverse proxy is in front - use long, unguessable topic names
- consider enabling auth and access control
- protect metrics if you enable them
If iOS devices need timely push behavior from a self-hosted ntfy server, read the upstream push notes carefully. The config usually needs upstream-base-url: "https://ntfy.sh" so the iOS app can be nudged to poll your server.
ntfy vs Gotify
Use ntfy when the integration should be a URL.
It shines for shell scripts, health checks, Docker update alerts, cron jobs, CI, and monitoring systems that already know how to make HTTP requests.
Use Gotify when you want a more account-centered notification server with application tokens, a clear dashboard, and persistent app identities.
The one-line version:
ntfy is topic-first and curl-first. Gotify is app-token-first and dashboard-first.
For a homelab, both are valid. I would pick ntfy for simple script alerts and Gotify when I want a more structured notification admin UI.
Conclusion
ntfy is one of those tools that stays useful because it does not ask for much.
You can run it as a single container, send messages with curl, subscribe from a phone or browser, and add auth, Web Push, e-mail, Matrix, or PostgreSQL only when the deployment actually needs them.
For self-hosters already using Uptime Kuma, Gotify, Home Assistant, or cron-based maintenance scripts, ntfy is worth having in the toolbox.
The ntfy Project
- The official ntfy site
- The ntfy source code on GitHub
- ntfy documentation
- License: Apache-2.0 and GPL-2.0
FAQ
Is ntfy open source?
Yes. The ntfy repository is dual licensed under Apache-2.0 and GPL-2.0.
The public hosted service also has paid plans, but the server code can be self-hosted.
Does ntfy need PostgreSQL?
No. ntfy can run with no database, with a local SQLite cache file, or with PostgreSQL.
The compose in this post uses a persistent SQLite message cache at /var/cache/ntfy/cache.db. PostgreSQL becomes more interesting if you want a larger multi-user instance with database-backed message cache, auth, web push subscriptions, and account data.
Is a topic name really a password?
In the simplest public ntfy mode, yes, effectively.
Anyone who knows the topic URL can publish or subscribe if you have not enabled access control. For private or exposed deployments, use long random topic names, HTTPS, auth, ACLs, or all of those.
How do I run ntfy on my homelab with auth, iPhone push, and Cloudflare Tunnel?
For the public homelab instance, use a real ntfy server.yml instead of only command-line flags. The important settings are:
base-url: the exact public HTTPS URLbehind-proxy: true: required when Cloudflare Tunnel or another reverse proxy is in frontauth-file: a persistent SQLite auth databaseauth-default-access: "deny-all": anonymous users get no access by defaultupstream-base-url: "https://ntfy.sh": needed for iPhone push notifications on self-hosted servers
In Home-Lab, the production config is now an actual file, not something hidden inside Docker:
cd /home/jalcocert/Home-Lab/ntfy
nano config/server.yml
Look for this exact path if you are trying to find the file:
/home/jalcocert/Home-Lab/ntfy/config/server.yml
It uses this shape:
base-url: "https://ntfy.jalcocertech.com"
listen-http: ":80"
cache-file: "/var/cache/ntfy/cache.db"
attachment-cache-dir: "/var/cache/ntfy/attachments"
auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all"
behind-proxy: true
upstream-base-url: "https://ntfy.sh"
The production compose is also a separate file:
/home/jalcocert/Home-Lab/ntfy/docker-compose.prod.yml
It mounts config/server.yml into the container and attaches ntfy to the existing Cloudflare Tunnel network. If your tunnel compose already creates a Docker network called cloudflared_tunnel, mark it as external in the ntfy compose:
services:
ntfy:
image: binwiederhier/ntfy:v2.28.0
container_name: ntfy
command: serve
environment:
TZ: ${TZ:-Europe/Madrid}
volumes:
- ./config/server.yml:/etc/ntfy/server.yml:ro
- ntfy_cache:/var/cache/ntfy
- ntfy_data:/var/lib/ntfy
expose:
- "80"
networks:
- default
- cloudflared_tunnel
restart: unless-stopped
volumes:
ntfy_cache:
ntfy_data:
networks:
cloudflared_tunnel:
external: true
Start the public/tunnel deployment with the production compose file:
sudo docker compose -f docker-compose.prod.yml up -d
sudo docker compose -f docker-compose.prod.yml logs -f ntfy
Create an admin user in the auth database:
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy user add --role=admin admin
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy access
For a less privileged publishing user, create a normal user and grant access only to the topics you want:
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy user add notifier
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy access notifier "alerts-*" rw
Test authenticated publishing:
curl -u admin \
-H "Title: ntfy auth test" \
-d "homelab ntfy is working" \
https://ntfy.jalcocertech.com/alerts-test
On iPhone, set the default server to:
https://ntfy.jalcocertech.com
Make sure it matches base-url exactly.
How should Cloudflare Tunnel point to ntfy?
In Cloudflare Zero Trust, create a public hostname:
Hostname: ntfy.jalcocertech.com
Service: http://ntfy:80
That works when the cloudflared container and the ntfy container share the same Docker network, here named cloudflared_tunnel.
If the tunnel is managed by a separate compose file, check the network name first:
docker network ls | grep cloudflared
Then make sure the ntfy compose joins that same external network:
networks:
cloudflared_tunnel:
external: true
Do not put Cloudflare Access in front of this hostname if the phone app or a site function needs to call ntfy directly. Cloudflare Access is useful for browser dashboards, but mobile apps, webhook publishers, serverless functions, and simple curl publishers often cannot complete the Access login flow.
Use ntfy’s own auth instead: auth-file, auth-default-access: "deny-all", users, and topic ACLs.
Can I just run docker compose up -d in the ntfy folder and expose it?
The simple Home-Lab compose will start ntfy, but it is not the public-exposure setup.
From the homelab:
cd /home/jalcocert/Home-Lab/ntfy
sudo docker compose up -d
That will run ntfy if Docker is working and the configured port is free. It also uses SQLite for the message cache at:
/var/cache/ntfy/cache.db
But it does not create a default username or password. ntfy has no magic default login in that compose file. If you expose that simple setup directly, topic names are effectively the only protection.
For https://ntfy.jalcocertech.com, use the production files before exposing it:
/home/jalcocert/Home-Lab/ntfy/config/server.yml
/home/jalcocert/Home-Lab/ntfy/docker-compose.prod.yml
The important server.yml lines are:
auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all"
behind-proxy: true
upstream-base-url: "https://ntfy.sh"
Then create the first admin user after the container starts:
sudo docker compose -f docker-compose.prod.yml up -d
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy user add --role=admin admin
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy access
The password is set interactively when you add the user. For normal publishers, create a regular user and grant access only to the topic pattern it needs:
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy user add notifier
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy access notifier "alerts-*" rw
Then test the exposed instance with authenticated publishing:
curl -u admin \
-H "Title: ntfy auth test" \
-d "homelab ntfy is working" \
https://ntfy.jalcocertech.com/alerts-test
So the practical rule is: the simple compose is fine for a local test, but the exposed tunnel should use ntfy auth plus deny-by-default access.
Where is server.yml and when is it created?
server.yml is a real file in the Home-Lab ntfy folder:
/home/jalcocert/Home-Lab/ntfy/config/server.yml
It is committed as a reusable config template for the production ntfy deployment.
If you are on a clone that does not have it yet, pull the latest Home-Lab changes:
cd /home/jalcocert/Home-Lab
git pull
Then check:
ls -la /home/jalcocert/Home-Lab/ntfy/config/server.yml
The file is mounted into the container by docker-compose.prod.yml:
volumes:
- ./config/server.yml:/etc/ntfy/server.yml:ro
So edit the host file at config/server.yml; do not look for it inside Docker first.
Does server.yml contain my ntfy password?
No. server.yml does not contain the ntfy admin password.
It only tells ntfy where to store the auth database:
auth-file: "/var/lib/ntfy/user.db"
The password is created later, when you run:
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy user add --role=admin admin
That command prompts for a password interactively. ntfy stores the resulting user record in:
/var/lib/ntfy/user.db
In this Docker setup, that path lives in the private Docker volume mounted here:
- ntfy_data:/var/lib/ntfy
The public repo contains server.yml and docker-compose.prod.yml. The private runtime user database is created on your server after you add users, and it is not committed to GitHub.
What do I put in the Cloudflare Tunnel service field?
Use the ntfy container name and internal port:
Service type: HTTP
URL: ntfy:80
For the public hostname, use:
Subdomain: ntfy
Domain: jalcocertech.com
That creates:
https://ntfy.jalcocertech.com
This works because docker-compose.prod.yml puts the ntfy service on the same Docker network as the Cloudflare Tunnel container:
networks:
- cloudflared_tunnel
Do not point the tunnel to localhost:8099 from the Cloudflare Tunnel container. Inside the cloudflared container, localhost means the tunnel container itself, not the ntfy container.
What does the ntfy admin user command mean?
This command creates the first admin user:
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy user add --role=admin admin
Breakdown:
sudo: run Docker with elevated permissionsdocker compose -f docker-compose.prod.yml: use the production compose fileexec ntfy: run the next command inside the runningntfyservice containerntfy user add: call the ntfy CLI and add a user--role=admin: give the user admin permissions- final
admin: the username to create
So yes: --role=admin is the role, and the last admin is the username.
You can use a different username:
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy user add --role=admin jalcocert
That creates an admin user named jalcocert.
Why does the ntfy web UI open without asking for a password?
Seeing the ntfy web page at:
https://ntfy.jalcocertech.com/
does not mean anonymous topic access is allowed.
The ntfy web UI shell can load in the browser, while publish and subscribe actions are still protected by ntfy auth. The real test is whether anonymous users can read or write a topic.
Test anonymous publishing:
curl -i -d "anonymous test" https://ntfy.jalcocertech.com/alerts-test
With auth-default-access: "deny-all", a blocked anonymous publish should look like:
HTTP/2 403
and the JSON body should say:
{"code":40301,"http":403,"error":"forbidden","link":"https://ntfy.sh/docs/publish/#authentication"}
That result means deny-by-default is working.
How do I test that ntfy auth is really working?
Test without credentials first:
curl -i -d "anonymous test" https://ntfy.jalcocertech.com/alerts-test
Expected result:
HTTP/2 403
Then test with a valid ntfy user:
curl -i -u yosuaperro \
-H "Title: auth test" \
-d "authenticated publish works" \
https://ntfy.jalcocertech.com/alerts-test
curl will ask for that user’s password:
Enter host password for user 'yosuaperro':
Expected result:
HTTP/2 200
and a JSON response with the published message:
{"event":"message","topic":"alerts-test","title":"auth test","message":"authenticated publish works"}
If anonymous gives 403 and the real user gives 200, ntfy auth is working correctly.
Why does admin fail but my weird username works?
The final word in the user creation command is the username.
This creates a user named admin:
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy user add --role=admin admin
This creates a user named yosuaperro:
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy user add --role=admin yosuaperro
If you intentionally created the admin account with a different username, then admin will fail:
curl -i -u admin \
-H "Title: auth test" \
-d "authenticated publish works" \
https://ntfy.jalcocertech.com/alerts-test
Expected result when that username/password is wrong or does not exist:
HTTP/2 401
Use the username you actually created:
curl -i -u yosuaperro \
-H "Title: auth test" \
-d "authenticated publish works" \
https://ntfy.jalcocertech.com/alerts-test
Expected result with the correct password:
HTTP/2 200
How do I list, verify, or reset ntfy users?
From the ntfy Home-Lab folder:
cd /home/jalcocert/Home-Lab/ntfy
List users and access rules:
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy access
Create a new admin user:
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy user add --role=admin yosuaperro
Change an existing user’s password:
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy user change-pass yosuaperro
Create a limited publisher user:
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy user add notifier
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy access notifier "alerts-*" rw
The user database lives in the Docker volume mounted at:
/var/lib/ntfy/user.db
It is not the public server.yml file.
How do I use this from the Android app?
In the Android ntfy app, add your self-hosted server:
Server: https://ntfy.jalcocertech.com
Then subscribe to a topic:
Topic: alerts-test
Because the server has:
auth-default-access: "deny-all"
anonymous users cannot publish or subscribe. The Android app should use a real ntfy username and password when it connects to protected topics.
If you use the admin account you created, that account can access every topic. For a phone-only account, create a limited user instead:
cd /home/jalcocert/Home-Lab/ntfy
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy user add android
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy access android "alerts-*" rw
Then in Android use:
Server: https://ntfy.jalcocertech.com
Username: android
Password: that user's password
Topic: alerts-test
The rule is simple: unauthenticated users cannot publish. Authenticated users can publish only if their role or topic ACL allows it. Admin users can publish everywhere.
How do I create and use ntfy tk_ access tokens?
ntfy can create tk_... access tokens from the server CLI. These are useful for phones, scripts, and automation because you can avoid reusing the user’s password everywhere.
Start from the ntfy Home-Lab folder:
cd /home/jalcocert/Home-Lab/ntfy
Create a token for an existing user:
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy token add yosuaperro
Create one with a label:
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy token add --label="android phone" yosuaperro
Create one that expires:
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy token add --expires=30d --label="android phone" yosuaperro
List tokens:
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy token list
List tokens for one user:
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy token list yosuaperro
Remove a token:
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy token remove yosuaperro tk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
If token list does not show the full token value you need, create a new token and replace it in the app or script that uses it. Then remove the old token.
Important security note: a token inherits that user’s permissions. If the user is an admin, the token is effectively admin-level. For a phone or script, prefer a limited user:
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy user add android
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy access android "alerts-*" rw
sudo docker compose -f docker-compose.prod.yml exec ntfy ntfy token add --label="android phone" android
Then use the returned tk_... token in the phone app or script instead of that user’s password.
Can I use ntfy with Uptime Kuma?
Yes. Uptime Kuma supports ntfy as a notification channel.
That gives you a compact monitoring path: Uptime Kuma detects downtime, ntfy delivers the push notification, and your phone subscribes to the chosen topic.
Which should I pick: ntfy or Gotify?
Pick ntfy if you want the simplest possible HTTP publish flow and topic-based subscriptions.
Pick Gotify if you prefer a more structured server with users, app tokens, a dashboard-first workflow, and a focused Android/WebSocket client model.
Comments