[{"data":1,"prerenderedAt":1444},["ShallowReactive",2],{"article-self-hosted-ntfy-notifications":3},{"id":4,"title":5,"body":6,"date":1430,"description":1431,"extension":1432,"meta":1433,"navigation":1229,"path":1434,"readingTime":1435,"seo":1436,"stem":1437,"tags":1438,"__hash__":1443},"articles\u002Fblog\u002Fself-hosted-ntfy-notifications.en.md","Self-Hosted ntfy: Your Own Push Notification Server",{"type":7,"value":8,"toc":1394},"minimark",[9,13,17,28,33,36,66,77,81,88,185,188,201,213,221,231,281,285,298,329,336,340,346,354,357,387,391,394,415,421,424,440,447,451,454,489,492,524,527,592,596,599,614,617,632,635,725,732,736,739,744,869,876,889,893,898,911,915,918,924,927,931,945,949,952,985,988,992,996,1009,1015,1075,1079,1082,1087,1122,1126,1129,1152,1155,1159,1162,1176,1179,1191,1195,1199,1202,1255,1259,1269,1273,1276,1289,1293,1296,1330,1334,1338,1341,1345,1348,1352,1355,1359,1362,1366,1373,1376,1380,1387,1390],[10,11,5],"h1",{"id":12},"self-hosted-ntfy-your-own-push-notification-server",[14,15,16],"p",{},"I run an AI agent (Hermes), monitoring tools, a blog, and a bunch of small services on my server. I needed to receive notifications from all this infrastructure on my phone — not via the Telegram Bot API (with its rate limits and dependency on Telegram's servers), but through my own channel that I control.",[14,18,19,20,27],{},"The solution is ",[21,22,26],"a",{"href":23,"rel":24},"https:\u002F\u002Fntfy.sh",[25],"nofollow","ntfy",": a lightweight Go server, HTTP pub-sub, Android\u002FiOS apps, and zero vendor lock-in. Here's how I set it up and what I learned.",[29,30,32],"h2",{"id":31},"installation","Installation",[14,34,35],{},"On Debian\u002FUbuntu, it's straightforward:",[37,38,43],"pre",{"className":39,"code":40,"language":41,"meta":42,"style":42},"language-bash shiki shiki-themes github-light catppuccin-mocha","apt-get install -y ntfy\n","bash","",[44,45,46],"code",{"__ignoreMap":42},[47,48,51,55,59,63],"span",{"class":49,"line":50},"line",1,[47,52,54],{"class":53},"siMrf","apt-get",[47,56,58],{"class":57},"sG7gF"," install",[47,60,62],{"class":61},"soLUO"," -y",[47,64,65],{"class":57}," ntfy\n",[14,67,68,69,72,73,76],{},"The package creates an ",[44,70,71],{},"_ntfy"," user, a systemd service, and a default config. But the \"default\" listens on ",[44,74,75],{},":80"," without authentication. For production, this needs to change.",[29,78,80],{"id":79},"configuration","Configuration",[14,82,83,84,87],{},"File ",[44,85,86],{},"\u002Fetc\u002Fntfy\u002Fserver.yml",":",[37,89,93],{"className":90,"code":91,"language":92,"meta":42,"style":42},"language-yaml shiki shiki-themes github-light catppuccin-mocha","base-url: \"https:\u002F\u002Fntfy.example.com\"\nlisten-http: \"127.0.0.1:8080\"\nbehind-proxy: true\ncache-file: \u002Fvar\u002Fcache\u002Fntfy\u002Fcache.db\ncache-duration: \"24h\"\nauth-file: \u002Fvar\u002Flib\u002Fntfy\u002Fuser.db\nauth-default-access: \"deny-all\"\nlog-level: info\n","yaml",[44,94,95,107,118,130,141,152,163,174],{"__ignoreMap":42},[47,96,97,101,104],{"class":49,"line":50},[47,98,100],{"class":99},"sEb-F","base-url",[47,102,87],{"class":103},"sgPNX",[47,105,106],{"class":57}," \"https:\u002F\u002Fntfy.example.com\"\n",[47,108,110,113,115],{"class":49,"line":109},2,[47,111,112],{"class":99},"listen-http",[47,114,87],{"class":103},[47,116,117],{"class":57}," \"127.0.0.1:8080\"\n",[47,119,121,124,126],{"class":49,"line":120},3,[47,122,123],{"class":99},"behind-proxy",[47,125,87],{"class":103},[47,127,129],{"class":128},"sNSVI"," true\n",[47,131,133,136,138],{"class":49,"line":132},4,[47,134,135],{"class":99},"cache-file",[47,137,87],{"class":103},[47,139,140],{"class":57}," \u002Fvar\u002Fcache\u002Fntfy\u002Fcache.db\n",[47,142,144,147,149],{"class":49,"line":143},5,[47,145,146],{"class":99},"cache-duration",[47,148,87],{"class":103},[47,150,151],{"class":57}," \"24h\"\n",[47,153,155,158,160],{"class":49,"line":154},6,[47,156,157],{"class":99},"auth-file",[47,159,87],{"class":103},[47,161,162],{"class":57}," \u002Fvar\u002Flib\u002Fntfy\u002Fuser.db\n",[47,164,166,169,171],{"class":49,"line":165},7,[47,167,168],{"class":99},"auth-default-access",[47,170,87],{"class":103},[47,172,173],{"class":57}," \"deny-all\"\n",[47,175,177,180,182],{"class":49,"line":176},8,[47,178,179],{"class":99},"log-level",[47,181,87],{"class":103},[47,183,184],{"class":57}," info\n",[14,186,187],{},"Let's break down the key points:",[14,189,190,196,197,200],{},[191,192,193],"strong",{},[44,194,195],{},"listen-http: \"127.0.0.1:8080\""," — listen only on localhost. All external traffic goes through the reverse proxy. If you set ",[44,198,199],{},"0.0.0.0:80",", ntfy will be accessible directly, bypassing SSL and rate limiting.",[14,202,203,208,209,212],{},[191,204,205],{},[44,206,207],{},"behind-proxy: true"," — mandatory if you're behind Caddy\u002Fnginx. Without this, ntfy won't see subscribers' real IP addresses (it will see ",[44,210,211],{},"127.0.0.1","), and rate limiting won't work correctly.",[14,214,215,220],{},[191,216,217],{},[44,218,219],{},"auth-default-access: \"deny-all\""," — everything is closed by default. Without this, anyone who guesses a topic name can read from and write to it. This is fine for the public ntfy.sh, but not for self-hosted.",[14,222,223,226,227,230],{},[191,224,225],{},"Pitfall",": Don't write the config via ",[44,228,229],{},"cat > \u002Fetc\u002Fntfy\u002Fserver.yml"," in the terminal — some security scanners block heredocs in bash. Use Python instead:",[37,232,236],{"className":233,"code":234,"language":235,"meta":42,"style":42},"language-python shiki shiki-themes github-light catppuccin-mocha","import pathlib\npathlib.Path('\u002Fetc\u002Fntfy\u002Fserver.yml').write_text(config_yaml)\n","python",[44,237,238,248],{"__ignoreMap":42},[47,239,240,244],{"class":49,"line":50},[47,241,243],{"class":242},"saXKZ","import",[47,245,247],{"class":246},"slTIY"," pathlib\n",[47,249,250,253,257,261,264,267,270,273,275,278],{"class":49,"line":109},[47,251,252],{"class":246},"pathlib",[47,254,256],{"class":255},"s_QEy",".",[47,258,260],{"class":259},"sPNDc","Path",[47,262,263],{"class":255},"(",[47,265,266],{"class":57},"'\u002Fetc\u002Fntfy\u002Fserver.yml'",[47,268,269],{"class":255},").",[47,271,272],{"class":259},"write_text",[47,274,263],{"class":255},[47,276,277],{"class":246},"config_yaml",[47,279,280],{"class":255},")\n",[29,282,284],{"id":283},"directory-permissions","Directory Permissions",[14,286,287,288,290,291,294,295,87],{},"The packaged ",[44,289,71],{}," user must have write permissions to ",[44,292,293],{},"\u002Fvar\u002Fcache\u002Fntfy"," and ",[44,296,297],{},"\u002Fvar\u002Flib\u002Fntfy",[37,299,301],{"className":39,"code":300,"language":41,"meta":42,"style":42},"mkdir -p \u002Fvar\u002Fcache\u002Fntfy \u002Fvar\u002Flib\u002Fntfy\nchown _ntfy:_ntfy \u002Fvar\u002Fcache\u002Fntfy \u002Fvar\u002Flib\u002Fntfy\n",[44,302,303,317],{"__ignoreMap":42},[47,304,305,308,311,314],{"class":49,"line":50},[47,306,307],{"class":53},"mkdir",[47,309,310],{"class":61}," -p",[47,312,313],{"class":57}," \u002Fvar\u002Fcache\u002Fntfy",[47,315,316],{"class":57}," \u002Fvar\u002Flib\u002Fntfy\n",[47,318,319,322,325,327],{"class":49,"line":109},[47,320,321],{"class":53},"chown",[47,323,324],{"class":57}," _ntfy:_ntfy",[47,326,313],{"class":57},[47,328,316],{"class":57},[14,330,331,332,335],{},"Without this, the service will crash with ",[44,333,334],{},"\"unable to open database file\"",". Classic.",[29,337,339],{"id":338},"caddy-reverse-proxy","Caddy Reverse Proxy",[14,341,342,343,345],{},"I have Caddy listening on ",[44,344,75],{}," (Cloudflare handles SSL termination externally). Config for ntfy:",[37,347,352],{"className":348,"code":350,"language":351},[349],"language-text","@ntfy host ntfy.example.com\nhandle @ntfy {\n    reverse_proxy localhost:8080\n}\n","text",[44,353,350],{"__ignoreMap":42},[14,355,356],{},"Two nuances:",[358,359,360,374],"ol",{},[361,362,363,366,367,294,370,373],"li",{},[191,364,365],{},"WebSocket"," — ntfy uses WebSocket for real-time subscriptions. Caddy proxies it automatically, but if you're behind nginx, you need to explicitly configure the ",[44,368,369],{},"Upgrade",[44,371,372],{},"Connection"," headers.",[361,375,376,379,380,382,383,386],{},[191,377,378],{},"Cloudflare SSL:Flexible"," — Cloudflare terminates SSL at its edge, and plain HTTP goes to the origin. That's why Caddy listens on ",[44,381,75],{},", not ",[44,384,385],{},":443",". If you switch to SSL:Full, you'll also need to configure a certificate on the origin — overkill for a self-hosted hobby project.",[29,388,390],{"id":389},"users-and-tokens","Users and Tokens",[14,392,393],{},"Create the first user:",[37,395,397],{"className":39,"code":396,"language":41,"meta":42,"style":42},"ntfy user add --role=admin admin\n",[44,398,399],{"__ignoreMap":42},[47,400,401,403,406,409,412],{"class":49,"line":50},[47,402,26],{"class":53},[47,404,405],{"class":57}," user",[47,407,408],{"class":57}," add",[47,410,411],{"class":61}," --role=admin",[47,413,414],{"class":57}," admin\n",[14,416,417,418,269],{},"The password is requested interactively. After this, all requests require authentication (due to ",[44,419,420],{},"deny-all",[14,422,423],{},"For programmatic access (scripts, AI agent), it's better to use tokens instead of login\u002Fpassword:",[37,425,427],{"className":39,"code":426,"language":41,"meta":42,"style":42},"ntfy token add admin\n",[44,428,429],{"__ignoreMap":42},[47,430,431,433,436,438],{"class":49,"line":50},[47,432,26],{"class":53},[47,434,435],{"class":57}," token",[47,437,408],{"class":57},[47,439,414],{"class":57},[14,441,442,443,446],{},"The token looks like ",[44,444,445],{},"tk_abc123...",". It is passed in the header `Authorization: Bearer ***",[29,448,450],{"id":449},"publishing-notifications","Publishing Notifications",[14,452,453],{},"Sending a message is a single HTTP POST:",[37,455,457],{"className":39,"code":456,"language":41,"meta":42,"style":42},"curl -u admin:password \\\n  -d \"Server overheated! CPU 95%\" \\\n  https:\u002F\u002Fntfy.example.com\u002Fmonitoring\n",[44,458,459,474,484],{"__ignoreMap":42},[47,460,461,464,467,470],{"class":49,"line":50},[47,462,463],{"class":53},"curl",[47,465,466],{"class":61}," -u",[47,468,469],{"class":57}," admin:password",[47,471,473],{"class":472},"s_VIv"," \\\n",[47,475,476,479,482],{"class":49,"line":109},[47,477,478],{"class":61},"  -d",[47,480,481],{"class":57}," \"Server overheated! CPU 95%\"",[47,483,473],{"class":472},[47,485,486],{"class":49,"line":120},[47,487,488],{"class":57},"  https:\u002F\u002Fntfy.example.com\u002Fmonitoring\n",[14,490,491],{},"Or with a token:",[37,493,495],{"className":39,"code":494,"language":41,"meta":42,"style":42},"curl -H \"Authorization: Bearer *** \\\n  -d \"Server overheated!\" \\\n  https:\u002F\u002Fntfy.example.com\u002Fmonitoring\n",[44,496,497,510,520],{"__ignoreMap":42},[47,498,499,501,504,507],{"class":49,"line":50},[47,500,463],{"class":53},[47,502,503],{"class":61}," -H",[47,505,506],{"class":57}," \"Authorization: Bearer *** ",[47,508,509],{"class":472},"\\\n",[47,511,512,515,518],{"class":49,"line":109},[47,513,514],{"class":57},"  -d \"Server",[47,516,517],{"class":57}," overheated!\" ",[47,519,509],{"class":472},[47,521,522],{"class":49,"line":120},[47,523,488],{"class":57},[14,525,526],{},"With headers for customization:",[37,528,530],{"className":39,"code":529,"language":41,"meta":42,"style":42},"curl -H \"Authorization: Bearer *** \\\n  -H \"Title: Monitoring\" \\\n  -H \"Priority: high\" \\\n  -H \"Tags: warning,fire\" \\\n  -d \"CPU 95%, RAM 90%\" \\\n  https:\u002F\u002Fntfy.example.com\u002Fmonitoring\n",[44,531,532,542,552,562,572,588],{"__ignoreMap":42},[47,533,534,536,538,540],{"class":49,"line":50},[47,535,463],{"class":53},[47,537,503],{"class":61},[47,539,506],{"class":57},[47,541,509],{"class":472},[47,543,544,547,550],{"class":49,"line":109},[47,545,546],{"class":57},"  -H \"Title:",[47,548,549],{"class":57}," Monitoring\" ",[47,551,509],{"class":472},[47,553,554,557,560],{"class":49,"line":120},[47,555,556],{"class":57},"  -H \"Priority:",[47,558,559],{"class":57}," high\" ",[47,561,509],{"class":472},[47,563,564,567,570],{"class":49,"line":132},[47,565,566],{"class":57},"  -H \"Tags:",[47,568,569],{"class":57}," warning,fire\" ",[47,571,509],{"class":472},[47,573,574,577,580,583,586],{"class":49,"line":143},[47,575,576],{"class":57},"  -d \"CPU",[47,578,579],{"class":57}," 95%,",[47,581,582],{"class":57}," RAM",[47,584,585],{"class":57}," 90%\" ",[47,587,509],{"class":472},[47,589,590],{"class":49,"line":154},[47,591,488],{"class":57},[29,593,595],{"id":594},"subscribing-to-notifications","Subscribing to Notifications",[14,597,598],{},"CLI:",[37,600,602],{"className":39,"code":601,"language":41,"meta":42,"style":42},"ntfy subscribe ntfy.example.com\u002Fmonitoring\n",[44,603,604],{"__ignoreMap":42},[47,605,606,608,611],{"class":49,"line":50},[47,607,26],{"class":53},[47,609,610],{"class":57}," subscribe",[47,612,613],{"class":57}," ntfy.example.com\u002Fmonitoring\n",[14,615,616],{},"HTTP (long-polling):",[37,618,620],{"className":39,"code":619,"language":41,"meta":42,"style":42},"curl -s ntfy.example.com\u002Fmonitoring\u002Fjson\n",[44,621,622],{"__ignoreMap":42},[47,623,624,626,629],{"class":49,"line":50},[47,625,463],{"class":53},[47,627,628],{"class":61}," -s",[47,630,631],{"class":57}," ntfy.example.com\u002Fmonitoring\u002Fjson\n",[14,633,634],{},"WebSocket (for integrations):",[37,636,640],{"className":637,"code":638,"language":639,"meta":42,"style":42},"language-javascript shiki shiki-themes github-light catppuccin-mocha","const ws = new WebSocket('wss:\u002F\u002Fntfy.example.com\u002Fmonitoring\u002Fws');\nws.onmessage = (e) => console.log(JSON.parse(e.data));\n","javascript",[44,641,642,673],{"__ignoreMap":42},[47,643,644,647,651,655,659,662,664,667,670],{"class":49,"line":50},[47,645,646],{"class":242},"const",[47,648,650],{"class":649},"sbIxs"," ws",[47,652,654],{"class":653},"s_Q3D"," =",[47,656,658],{"class":657},"sf7P5"," new",[47,660,661],{"class":53}," WebSocket",[47,663,263],{"class":246},[47,665,666],{"class":57},"'wss:\u002F\u002Fntfy.example.com\u002Fmonitoring\u002Fws'",[47,668,669],{"class":246},")",[47,671,672],{"class":255},";\n",[47,674,675,678,680,683,685,688,692,694,697,700,702,705,707,710,712,715,718,720,723],{"class":49,"line":109},[47,676,677],{"class":246},"ws",[47,679,256],{"class":103},[47,681,682],{"class":53},"onmessage",[47,684,654],{"class":653},[47,686,687],{"class":255}," (",[47,689,691],{"class":690},"s-dMd","e",[47,693,669],{"class":255},[47,695,696],{"class":242}," =>",[47,698,699],{"class":246}," console",[47,701,256],{"class":103},[47,703,704],{"class":53},"log",[47,706,263],{"class":246},[47,708,709],{"class":128},"JSON",[47,711,256],{"class":103},[47,713,714],{"class":53},"parse",[47,716,717],{"class":246},"(e",[47,719,256],{"class":103},[47,721,722],{"class":246},"data))",[47,724,672],{"class":255},[14,726,727,728,731],{},"Android\u002FiOS app — just add the server ",[44,729,730],{},"https:\u002F\u002Fntfy.example.com"," and subscribe to topics.",[29,733,735],{"id":734},"integration-with-hermes-webhooks","Integration with Hermes: Webhooks",[14,737,738],{},"The most interesting part is connecting ntfy to an AI agent. Hermes has a webhook system, and ntfy supports actions — automatic HTTP requests triggered when a message is received.",[14,740,741,742,87],{},"In ",[44,743,86],{},[37,745,747],{"className":90,"code":746,"language":92,"meta":42,"style":42},"actions:\n  - action: \"webhook\"\n    label: \"Forward to Hermes\"\n    url: \"http:\u002F\u002Flocalhost:8644\u002Fwebhooks\u002Fntfy\"\n    headers:\n      Authorization: \"Bearer my-hermes-secret\"\n    body: |\n      {\n        \"topic\": \"{{ .Topic }}\",\n        \"message\": \"{{ .Message }}\",\n        \"title\": \"{{ .Title }}\",\n        \"sender\": \"{{ .Sender }}\",\n        \"time\": \"{{ .Time }}\"\n      }\n    topic: \"hermes\"\n",[44,748,749,757,770,780,790,797,807,817,822,828,834,840,846,852,858],{"__ignoreMap":42},[47,750,751,754],{"class":49,"line":50},[47,752,753],{"class":99},"actions",[47,755,756],{"class":103},":\n",[47,758,759,762,765,767],{"class":49,"line":109},[47,760,761],{"class":255},"  -",[47,763,764],{"class":99}," action",[47,766,87],{"class":103},[47,768,769],{"class":57}," \"webhook\"\n",[47,771,772,775,777],{"class":49,"line":120},[47,773,774],{"class":99},"    label",[47,776,87],{"class":103},[47,778,779],{"class":57}," \"Forward to Hermes\"\n",[47,781,782,785,787],{"class":49,"line":132},[47,783,784],{"class":99},"    url",[47,786,87],{"class":103},[47,788,789],{"class":57}," \"http:\u002F\u002Flocalhost:8644\u002Fwebhooks\u002Fntfy\"\n",[47,791,792,795],{"class":49,"line":143},[47,793,794],{"class":99},"    headers",[47,796,756],{"class":103},[47,798,799,802,804],{"class":49,"line":154},[47,800,801],{"class":99},"      Authorization",[47,803,87],{"class":103},[47,805,806],{"class":57}," \"Bearer my-hermes-secret\"\n",[47,808,809,812,814],{"class":49,"line":165},[47,810,811],{"class":99},"    body",[47,813,87],{"class":103},[47,815,816],{"class":242}," |\n",[47,818,819],{"class":49,"line":176},[47,820,821],{"class":57},"      {\n",[47,823,825],{"class":49,"line":824},9,[47,826,827],{"class":57},"        \"topic\": \"{{ .Topic }}\",\n",[47,829,831],{"class":49,"line":830},10,[47,832,833],{"class":57},"        \"message\": \"{{ .Message }}\",\n",[47,835,837],{"class":49,"line":836},11,[47,838,839],{"class":57},"        \"title\": \"{{ .Title }}\",\n",[47,841,843],{"class":49,"line":842},12,[47,844,845],{"class":57},"        \"sender\": \"{{ .Sender }}\",\n",[47,847,849],{"class":49,"line":848},13,[47,850,851],{"class":57},"        \"time\": \"{{ .Time }}\"\n",[47,853,855],{"class":49,"line":854},14,[47,856,857],{"class":57},"      }\n",[47,859,861,864,866],{"class":49,"line":860},15,[47,862,863],{"class":99},"    topic",[47,865,87],{"class":103},[47,867,868],{"class":57}," \"hermes\"\n",[14,870,871,872,875],{},"Now every message in the ",[44,873,874],{},"hermes"," topic is automatically forwarded to the Hermes API. The agent can process the notification and respond.",[14,877,878,881,882,884,885,888],{},[191,879,880],{},"Trap",": If Hermes replies to the same ",[44,883,874],{}," topic, ntfy triggers the webhook again, Hermes replies again — an infinite loop. Solution: reply to a different topic (e.g., ",[44,886,887],{},"hermes-responses","), or filter by sender\u002Fheaders in the handler.",[29,890,892],{"id":891},"real-world-use-cases","Real-World Use Cases",[894,895,897],"h3",{"id":896},"server-monitoring","Server Monitoring",[14,899,900,901,904,905,908,909,256],{},"A cron script checks CPU, RAM, and disk every 5 minutes. If a threshold is exceeded, it sends an HTTP POST to ntfy. Priority ",[44,902,903],{},"high",", tag ",[44,906,907],{},"warning"," — and you instantly see the problem on your phone. The script is 5 lines of bash, initialization is a single ",[44,910,463],{},[894,912,914],{"id":913},"alerts-from-an-ai-agent","Alerts from an AI Agent",[14,916,917],{},"Hermes runs a cron job (daily briefing) and sends the result to ntfy:",[37,919,922],{"className":920,"code":921,"language":351},[349],"hermes cron job → Hermes API → POST ntfy.example.com\u002Fhermes\n",[44,923,921],{"__ignoreMap":42},[14,925,926],{},"The user sees a notification on their phone, opens it, and finds a summary of news, tasks, and service statuses.",[894,928,930],{"id":929},"deployment-notifications","Deployment Notifications",[14,932,933,934,937,938,941,942,944],{},"A CI\u002FCD pipeline (or simple script) sends deployment status: tag ",[44,935,936],{},"white_check_mark"," for success, ",[44,939,940],{},"x"," for failure, priority ",[44,943,903],{}," for critical errors. One POST request — and you see the result on your phone without opening the CI dashboard.",[29,946,948],{"id":947},"why-not-telegram","Why Not Telegram",[14,950,951],{},"Telegram Bot API is a great option, and I use it too. But self-hosted ntfy has advantages:",[953,954,955,961,967,973,979],"ul",{},[361,956,957,960],{},[191,958,959],{},"No rate limits"," from Telegram (30 messages\u002Fsec per chat)",[361,962,963,966],{},[191,964,965],{},"No dependency"," on Telegram servers (they go down sometimes)",[361,968,969,972],{},[191,970,971],{},"Full control"," over data (notifications don't pass through Telegram)",[361,974,975,978],{},[191,976,977],{},"No need"," for a Bot Token and chat ID — just an HTTP POST",[361,980,981,984],{},[191,982,983],{},"WebSocket subscriptions"," built-in, no polling required",[14,986,987],{},"Downsides: no rich content (buttons, inline keyboards), no group chats with history, no E2E encryption. For monitoring and alerts — perfect. As a messenger — no.",[29,989,991],{"id":990},"security-what-could-go-wrong","Security: What Could Go Wrong",[894,993,995],{"id":994},"public-topics","Public Topics",[14,997,998,999,1001,1002,1004,1005,1008],{},"If ",[44,1000,168],{}," isn't ",[44,1003,420],{},", anyone who guesses a topic name can read from it. Topic names are not secrets. ",[44,1006,1007],{},"ntfy subscribe ntfy.example.com\u002Fmy-secret-topic"," is not protection; it's security through obscurity.",[14,1010,1011,1012,1014],{},"Always set ",[44,1013,219],{}," and create users with explicit permissions for specific topics:",[37,1016,1018],{"className":39,"code":1017,"language":41,"meta":42,"style":42},"ntfy access admin monitoring rw\nntfy access admin hermes rw\nntfy access bot-hermes hermes rw\nntfy access bot-hermes monitoring ro\n",[44,1019,1020,1036,1049,1062],{"__ignoreMap":42},[47,1021,1022,1024,1027,1030,1033],{"class":49,"line":50},[47,1023,26],{"class":53},[47,1025,1026],{"class":57}," access",[47,1028,1029],{"class":57}," admin",[47,1031,1032],{"class":57}," monitoring",[47,1034,1035],{"class":57}," rw\n",[47,1037,1038,1040,1042,1044,1047],{"class":49,"line":109},[47,1039,26],{"class":53},[47,1041,1026],{"class":57},[47,1043,1029],{"class":57},[47,1045,1046],{"class":57}," hermes",[47,1048,1035],{"class":57},[47,1050,1051,1053,1055,1058,1060],{"class":49,"line":120},[47,1052,26],{"class":53},[47,1054,1026],{"class":57},[47,1056,1057],{"class":57}," bot-hermes",[47,1059,1046],{"class":57},[47,1061,1035],{"class":57},[47,1063,1064,1066,1068,1070,1072],{"class":49,"line":132},[47,1065,26],{"class":53},[47,1067,1026],{"class":57},[47,1069,1057],{"class":57},[47,1071,1032],{"class":57},[47,1073,1074],{"class":57}," ro\n",[894,1076,1078],{"id":1077},"rate-limiting","Rate Limiting",[14,1080,1081],{},"ntfy has built-in rate limiting (default 250 messages per day per visitor). For self-hosted setups with 1-2 users, this is more than enough. But if you send alerts every 10 seconds, you might hit the limit.",[14,1083,1084,1085,87],{},"Configuration in ",[44,1086,86],{},[37,1088,1090],{"className":90,"code":1089,"language":92,"meta":42,"style":42},"visitor-subscription-limit: 30\nvisitor-request-limit-burst: 60\nvisitor-request-limit-replenish: 5s\n",[44,1091,1092,1102,1112],{"__ignoreMap":42},[47,1093,1094,1097,1099],{"class":49,"line":50},[47,1095,1096],{"class":99},"visitor-subscription-limit",[47,1098,87],{"class":103},[47,1100,1101],{"class":128}," 30\n",[47,1103,1104,1107,1109],{"class":49,"line":109},[47,1105,1106],{"class":99},"visitor-request-limit-burst",[47,1108,87],{"class":103},[47,1110,1111],{"class":128}," 60\n",[47,1113,1114,1117,1119],{"class":49,"line":120},[47,1115,1116],{"class":99},"visitor-request-limit-replenish",[47,1118,87],{"class":103},[47,1120,1121],{"class":57}," 5s\n",[894,1123,1125],{"id":1124},"logging","Logging",[14,1127,1128],{},"ntfy writes logs to stdout (systemd journal). For production, it's worth configuring logging:",[37,1130,1132],{"className":90,"code":1131,"language":92,"meta":42,"style":42},"log-level: info\nlog-format: json\n",[44,1133,1134,1142],{"__ignoreMap":42},[47,1135,1136,1138,1140],{"class":49,"line":50},[47,1137,179],{"class":99},[47,1139,87],{"class":103},[47,1141,184],{"class":57},[47,1143,1144,1147,1149],{"class":49,"line":109},[47,1145,1146],{"class":99},"log-format",[47,1148,87],{"class":103},[47,1150,1151],{"class":57}," json\n",[14,1153,1154],{},"JSON format is easier to parse in Loki\u002FELK, but text is sufficient for a hobby project.",[29,1156,1158],{"id":1157},"migration-and-backups","Migration and Backups",[14,1160,1161],{},"ntfy stores everything in two files:",[953,1163,1164,1170],{},[361,1165,1166,1169],{},[44,1167,1168],{},"\u002Fvar\u002Fcache\u002Fntfy\u002Fcache.db"," — message cache (SQLite)",[361,1171,1172,1175],{},[44,1173,1174],{},"\u002Fvar\u002Flib\u002Fntfy\u002Fuser.db"," — users and tokens (SQLite)",[14,1177,1178],{},"For backups, simply copy these files. To migrate to another server, transfer the files and config.",[14,1180,1181,1183,1184,1186,1187,1190],{},[191,1182,225],{},": When updating ntfy via apt, the config ",[44,1185,86],{}," may be overwritten. Use ",[44,1188,1189],{},"dpkg --force-confold"," or back up the config separately.",[29,1192,1194],{"id":1193},"troubleshooting","Troubleshooting",[894,1196,1198],{"id":1197},"ntfy-wont-start-unable-to-open-database-file","ntfy won't start: \"unable to open database file\"",[14,1200,1201],{},"The most common issue. Check:",[37,1203,1205],{"className":39,"code":1204,"language":41,"meta":42,"style":42},"ls -la \u002Fvar\u002Fcache\u002Fntfy \u002Fvar\u002Flib\u002Fntfy\n# Should be owned by _ntfy:_ntfy\n\njournalctl -u ntfy --no-pager -n 20\n# Check recent logs\n",[44,1206,1207,1219,1225,1231,1250],{"__ignoreMap":42},[47,1208,1209,1212,1215,1217],{"class":49,"line":50},[47,1210,1211],{"class":53},"ls",[47,1213,1214],{"class":61}," -la",[47,1216,313],{"class":57},[47,1218,316],{"class":57},[47,1220,1221],{"class":49,"line":109},[47,1222,1224],{"class":1223},"skkvY","# Should be owned by _ntfy:_ntfy\n",[47,1226,1227],{"class":49,"line":120},[47,1228,1230],{"emptyLinePlaceholder":1229},true,"\n",[47,1232,1233,1236,1238,1241,1244,1247],{"class":49,"line":132},[47,1234,1235],{"class":53},"journalctl",[47,1237,466],{"class":61},[47,1239,1240],{"class":57}," ntfy",[47,1242,1243],{"class":61}," --no-pager",[47,1245,1246],{"class":61}," -n",[47,1248,1249],{"class":128}," 20\n",[47,1251,1252],{"class":49,"line":143},[47,1253,1254],{"class":1223},"# Check recent logs\n",[894,1256,1258],{"id":1257},"notifications-arent-arriving-via-cloudflare","Notifications aren't arriving via Cloudflare",[14,1260,1261,1262,1265,1266,256],{},"Ensure Cloudflare isn't caching API responses. ntfy API endpoints (",[44,1263,1264],{},"\u002Fv1\u002F...",") should not be cached. In the Dashboard: Caching → Configuration → Cache Level: Bypass for ",[44,1267,1268],{},"ntfy.example.com\u002Fv1\u002F*",[894,1270,1272],{"id":1271},"websocket-wont-connect","WebSocket won't connect",[14,1274,1275],{},"Behind Cloudflare, WebSocket works, but ensure that:",[358,1277,1278,1283,1286],{},[361,1279,1280,1282],{},[44,1281,207],{}," is set in the ntfy config",[361,1284,1285],{},"Cloudflare isn't blocking WebSocket (Free plan doesn't block it)",[361,1287,1288],{},"Caddy\u002Fnginx proxies Upgrade headers",[894,1290,1292],{"id":1291},"_403-forbidden-when-publishing","\"403 Forbidden\" when publishing",[14,1294,1295],{},"The user lacks access to the topic. Check:",[37,1297,1299],{"className":39,"code":1298,"language":41,"meta":42,"style":42},"ntfy access LIST  # Who has access to what\nntfy access admin my-topic rw  # Grant access\n",[44,1300,1301,1313],{"__ignoreMap":42},[47,1302,1303,1305,1307,1310],{"class":49,"line":50},[47,1304,26],{"class":53},[47,1306,1026],{"class":57},[47,1308,1309],{"class":57}," LIST",[47,1311,1312],{"class":1223},"  # Who has access to what\n",[47,1314,1315,1317,1319,1321,1324,1327],{"class":49,"line":109},[47,1316,26],{"class":53},[47,1318,1026],{"class":57},[47,1320,1029],{"class":57},[47,1322,1323],{"class":57}," my-topic",[47,1325,1326],{"class":57}," rw",[47,1328,1329],{"class":1223},"  # Grant access\n",[29,1331,1333],{"id":1332},"alternatives","Alternatives",[894,1335,1337],{"id":1336},"gotify","Gotify",[14,1339,1340],{},"Similar to ntfy, but written in Go with a different API. Smaller community, fewer apps. If you've already chosen ntfy, there's no reason to switch.",[894,1342,1344],{"id":1343},"apprise","Apprise",[14,1346,1347],{},"A Python library for sending notifications to 80+ services (Telegram, Slack, Discord, ntfy, etc.). Great as a unified sender, but not as a server. Can be combined: Apprise → ntfy → phone.",[894,1349,1351],{"id":1350},"shoutrrr","Shoutrrr",[14,1353,1354],{},"A Go alternative to Apprise. Same approach — a unified interface for various notification backends.",[894,1356,1358],{"id":1357},"telegram-bot-api","Telegram Bot API",[14,1360,1361],{},"The most obvious option, and I use it too. But ntfy wins for self-hosted setups: no Telegram rate limits, no dependency on Telegram servers, full data control. Telegram is for the messenger experience. ntfy is for infrastructure alerts.",[29,1363,1365],{"id":1364},"resources","Resources",[14,1367,1368,1369,1372],{},"On a typical VPS, ntfy consumes ",[191,1370,1371],{},"~18 MB of RAM"," and barely loads the CPU. Over 4 hours of operation: 51 messages published, 3 subscribers, 4 active topics. A drop in the ocean.",[14,1374,1375],{},"For comparison: Prometheus + Alertmanager consume ~200 MB. Grafana takes another ~150 MB. ntfy with webhook integration covers 80% of monitoring use cases for a hobby project without all that infrastructure overhead.",[29,1377,1379],{"id":1378},"summary","Summary",[14,1381,1382,1383,1386],{},"ntfy is fast: from ",[44,1384,1385],{},"apt install"," to working push notifications with minimal configuration. A Go binary, a systemd service, an HTTP API. Install it, configure Caddy, create a user — and you have your own notification backend that depends on no one.",[14,1388,1389],{},"For a self-hosted AI agent, it's a must-have. Monitoring, alerts, task notifications — all via one simple protocol.",[1391,1392,1393],"style",{},"html pre.shiki code .siMrf, html code.shiki .siMrf{--shiki-light:#6F42C1;--shiki-light-font-style:inherit;--shiki-dark:#89B4FA;--shiki-dark-font-style:italic}html pre.shiki code .sG7gF, html code.shiki .sG7gF{--shiki-light:#032F62;--shiki-dark:#A6E3A1}html pre.shiki code .soLUO, html code.shiki .soLUO{--shiki-light:#005CC5;--shiki-dark:#A6E3A1}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sEb-F, html code.shiki .sEb-F{--shiki-light:#22863A;--shiki-dark:#89B4FA}html pre.shiki code .sgPNX, html code.shiki .sgPNX{--shiki-light:#24292E;--shiki-dark:#94E2D5}html pre.shiki code .sNSVI, html code.shiki .sNSVI{--shiki-light:#005CC5;--shiki-dark:#FAB387}html pre.shiki code .saXKZ, html code.shiki .saXKZ{--shiki-light:#D73A49;--shiki-dark:#CBA6F7}html pre.shiki code .slTIY, html code.shiki .slTIY{--shiki-light:#24292E;--shiki-dark:#CDD6F4}html pre.shiki code .s_QEy, html code.shiki .s_QEy{--shiki-light:#24292E;--shiki-dark:#9399B2}html pre.shiki code .sPNDc, html code.shiki .sPNDc{--shiki-light:#24292E;--shiki-dark:#89B4FA}html pre.shiki code .s_VIv, html code.shiki .s_VIv{--shiki-light:#005CC5;--shiki-dark:#F5C2E7}html pre.shiki code .sbIxs, html code.shiki .sbIxs{--shiki-light:#005CC5;--shiki-dark:#CDD6F4}html pre.shiki code .s_Q3D, html code.shiki .s_Q3D{--shiki-light:#D73A49;--shiki-dark:#94E2D5}html pre.shiki code .sf7P5, html code.shiki .sf7P5{--shiki-light:#D73A49;--shiki-light-font-weight:inherit;--shiki-dark:#CBA6F7;--shiki-dark-font-weight:bold}html pre.shiki code .s-dMd, html code.shiki .s-dMd{--shiki-light:#E36209;--shiki-light-font-style:inherit;--shiki-dark:#EBA0AC;--shiki-dark-font-style:italic}html pre.shiki code .skkvY, html code.shiki .skkvY{--shiki-light:#6A737D;--shiki-light-font-style:inherit;--shiki-dark:#9399B2;--shiki-dark-font-style:italic}",{"title":42,"searchDepth":109,"depth":109,"links":1395},[1396,1397,1398,1399,1400,1401,1402,1403,1404,1409,1410,1415,1416,1422,1428,1429],{"id":31,"depth":109,"text":32},{"id":79,"depth":109,"text":80},{"id":283,"depth":109,"text":284},{"id":338,"depth":109,"text":339},{"id":389,"depth":109,"text":390},{"id":449,"depth":109,"text":450},{"id":594,"depth":109,"text":595},{"id":734,"depth":109,"text":735},{"id":891,"depth":109,"text":892,"children":1405},[1406,1407,1408],{"id":896,"depth":120,"text":897},{"id":913,"depth":120,"text":914},{"id":929,"depth":120,"text":930},{"id":947,"depth":109,"text":948},{"id":990,"depth":109,"text":991,"children":1411},[1412,1413,1414],{"id":994,"depth":120,"text":995},{"id":1077,"depth":120,"text":1078},{"id":1124,"depth":120,"text":1125},{"id":1157,"depth":109,"text":1158},{"id":1193,"depth":109,"text":1194,"children":1417},[1418,1419,1420,1421],{"id":1197,"depth":120,"text":1198},{"id":1257,"depth":120,"text":1258},{"id":1271,"depth":120,"text":1272},{"id":1291,"depth":120,"text":1292},{"id":1332,"depth":109,"text":1333,"children":1423},[1424,1425,1426,1427],{"id":1336,"depth":120,"text":1337},{"id":1343,"depth":120,"text":1344},{"id":1350,"depth":120,"text":1351},{"id":1357,"depth":120,"text":1358},{"id":1364,"depth":109,"text":1365},{"id":1378,"depth":109,"text":1379},"2026-05-24","How to set up your own ntfy push notification server: Caddy reverse proxy, access tokens, integration with an AI agent via webhooks. Monitoring, alerts, and an infinite loop trap.","md",{},"\u002Fblog\u002Fself-hosted-ntfy-notifications.en",null,{"title":5,"description":1431},"blog\u002Fself-hosted-ntfy-notifications.en",[26,1439,1440,1441,1442,874],"self-hosted","notifications","vps","caddy","rn-9GUC1HQPKVpoPVMbQVtlo2UjaYvHsk5JUmKI7fKo",1780777846250]