{"title":"Self-Hosted ntfy And The Shared IPv6 Quota Behind My 429s","html_url":"https://deallocatedobjects.com/posts/self-hosted-ntfy-and-the-shared-ipv6-quota-behind-my-429s","source_url":"","markdown_content":"My server sends push notifications to my phone when something needs a look: memory running low, a process that stopped answering on its port, a nightly job that failed. For a long time those went through [ntfy](https://ntfy.sh), which is about the nicest way there is to get a message from a shell script onto a phone. You POST some text to a URL and your phone buzzes:\n\n```bash\ncurl -d \"Backup job failed\" https://ntfy.sh/your-alert-topic\n```\n\nThen one day every alert started coming back `429 Too Many Requests`. Nothing on my side had changed, and the same request from my Mac went through fine.\n\n### What ntfy.sh Counts\n\nntfy.sh is free and has no accounts for publishing, so its limits are per visitor, and a visitor is an IP address. The [publishing docs](https://docs.ntfy.sh/publish/#limitations) list the daily cap: \"On ntfy.sh, the daily message limit is 250.\"\n\nThe detail that matters is how ntfy treats IPv6. From the [server config docs](https://docs.ntfy.sh/config/#ipv6-considerations):\n\n> By default, rate limiting for IPv6 is done using the `/64` subnet of the visitor's IPv6 address. This means that all visitors in the same `/64` subnet are treated as one visitor.\n\nThat's a sensible default. Home internet providers routinely hand each customer a /64 or bigger, so counting single IPv6 addresses would let anyone dodge the limit by rotating through their own block. An IPv4 address, on the other hand, counts on its own (`visitor-prefix-bits-ipv4` defaults to 32).\n\n### Where The /64 Comes From\n\nCloud providers don't always work like home providers. DigitalOcean's [IPv6 limits page](https://docs.digitalocean.com/products/networking/ipv6/details/limits/) says a droplet gets \"a maximum of 16 addresses (a subnet mask of `/124`)\". The interface itself is configured with a /64 prefix, but my droplet only owns 16 addresses inside it. The rest of that /64 belongs to other droplets.\n\nSo from ntfy.sh's point of view, my server and every other droplet in that /64 that talks to ntfy.sh are one visitor sharing one 250 message budget. That shared budget was empty, and my alerts were getting turned away with it.\n\nOver IPv4, my server is the only one on its address. When a host has both an IPv6 and an IPv4 address and the server can reach both, `curl` tries IPv6 first, so every alert had been going out over the shared one.\n\n### Checking It Yourself\n\nntfy.sh will tell you how it sees you. `GET /v1/account` without credentials describes the anonymous visitor you're being counted as, including messages sent and remaining today. Ask once over each address family and compare:\n\n```bash\ncurl -6 -s https://ntfy.sh/v1/account | jq '.limits.basis, .stats'\ncurl -4 -s https://ntfy.sh/v1/account | jq '.limits.basis, .stats'\n```\n\n`basis` comes back as `\"ip\"`, and `stats.messages_remaining` is the number to look at. If the IPv6 line is at zero while you've barely sent anything, you're sharing a /64. The daily counter resets at midnight UTC.\n\n### The Quick Fix: curl -4\n\nThe fastest fix is one flag. `-4` makes curl resolve and connect over IPv4 only:\n\n```bash\ncurl -4 -d \"Backup job failed\" https://ntfy.sh/your-alert-topic\n```\n\nWhen I tested it, the same request that got a 429 over IPv6 got a 200 over IPv4. If you use ntfy.sh from a cloud server and want alerts back in five minutes, that's the change to make.\n\nIt only covers the senders you remember to change, though. Anything else on the server that talks to ntfy.sh (a cron job, an app's own notification code, a library with its own HTTP client) still spends from whichever budget it lands on, and your important alerts share both budgets with all of it. I wanted my alerts to stop depending on a free public quota at all.\n\n### Running My Own ntfy\n\nntfy is a single Go binary with an official apt repository, and it's the same open-source server that ntfy.sh runs. I installed the package, which comes with a systemd unit and an `ntfy` user, and kept it on loopback behind nginx. The config that matters, from `/etc/ntfy/server.yml`:\n\n```yaml title=\"/etc/ntfy/server.yml\"\nbase-url: \"https://ntfy.example.com\"\nlisten-http: \"127.0.0.1:2586\"\nbehind-proxy: true\n\n# Nothing is readable or writable without a user or token.\nauth-file: \"/var/lib/ntfy/user.db\"\nauth-default-access: \"deny-all\"\nenable-signup: false\nenable-login: true\nrequire-login: true\n\n# Keep messages for 48 hours so the phone can catch up after being offline.\ncache-file: \"/var/cache/ntfy/cache.db\"\ncache-duration: \"48h\"\n\n# iOS instant delivery (more on this below).\nupstream-base-url: \"https://ntfy.sh\"\n\n# Scripts on the server publish over loopback and skip the request limit.\nvisitor-request-limit-exempt-hosts: \"127.0.0.1,::1\"\n```\n\n`auth-default-access: \"deny-all\"` is the line that makes it private. Out of the box ntfy lets anyone read and write any topic, which is how ntfy.sh works and why its topic names have to be hard to guess. With `deny-all`, an unknown topic is closed to everyone until you grant access to it.\n\n`behind-proxy: true` tells ntfy to take the client's address from `X-Forwarded-For`. Without it, every request arrives from nginx's 127.0.0.1 and every phone and browser gets rate limited as one visitor.\n\n### Users And Tokens\n\nI made three users with the `ntfy` CLI, each with only what it needs:\n\n```bash\nsudo -u ntfy ntfy user add --role=admin admin-user\nsudo -u ntfy ntfy user add publisher\nsudo -u ntfy ntfy user add phone\n\nsudo -u ntfy ntfy access publisher your-alert-topic write-only\nsudo -u ntfy ntfy access phone your-alert-topic read-only\n\nsudo -u ntfy ntfy token add --label=server-alerts publisher\n```\n\nThe scripts on the server never use a password. They use an access token (`tk_...`) for the `publisher` user, which can post to the alert topic and can't read anything. The phone signs in as `phone`, which can read the topic and can't post. If either credential leaks, the damage stops at that one ability on that one topic.\n\nThe token lives in a small env file owned by root, with a group for the accounts allowed to send alerts, mode 0640. `ntfy user add` reads the password from the `NTFY_PASSWORD` environment variable if it's set, so it never has to show up in a command line.\n\n### The nginx Side\n\nnginx terminates TLS and passes everything to the loopback port:\n\n```nginx title=\"/etc/nginx/sites-available/ntfy.example.com\"\nserver {\n  listen 443 ssl;\n  server_name ntfy.example.com;\n  access_log off;   # topic names are in the URLs\n\n  location / {\n    proxy_pass http://127.0.0.1:2586;\n    proxy_http_version 1.1;\n    proxy_buffering off;                        # streaming subscriptions\n    proxy_set_header Host $http_host;\n    proxy_set_header Upgrade $http_upgrade;     # WebSocket subscriptions\n    proxy_set_header Connection \"upgrade\";\n    proxy_set_header X-Forwarded-For $remote_addr;\n    proxy_set_header X-Forwarded-Proto $scheme;\n    proxy_read_timeout 3m;\n  }\n}\n```\n\nTwo lines are easy to get wrong. `proxy_buffering off` matters because the web app and some clients hold a connection open and stream messages as they arrive, and a buffering proxy sits on them. And `X-Forwarded-For $remote_addr` overwrites the header instead of appending to it (which is what `$proxy_add_x_forwarded_for` does), so a client can't send its own `X-Forwarded-For` and pick which visitor ntfy's rate limiter thinks it is.\n\nI also turned the access log off for this host. Topic names appear in every URL, and on a private server the topic name is part of the secret.\n\n### One Script For Every Sender\n\nEverything on the server that wants to buzz my phone calls one script, `/usr/local/sbin/notify-phone`, with a title as its argument and the body on stdin. It tries my server first and falls back to ntfy.sh over IPv4:\n\n```bash title=\"/usr/local/sbin/notify-phone\"\n#!/bin/bash\nset -uo pipefail\nTITLE=\"${1:-server alert}\"; PRIO=\"${2:-default}\"; TAGS=\"${3:-warning}\"\n. /etc/profile.d/ntfy.sh                 # NTFY_TOPIC, NTFY_URL (not secret)\n. /etc/notify-phone/publish.env          # NTFY_TOKEN (root:alerts 0640)\nBODY=$(head -c 3800)                     # ntfy messages max out at 4,096 bytes\n\ncode=$(printf '%s' \"$BODY\" | curl -sS --max-time 15 -o /dev/null -w '%{http_code}' \\\n  -H @<(printf 'Authorization: Bearer %s\\n' \"$NTFY_TOKEN\") \\\n  -H \"Title: $TITLE\" -H \"Priority: $PRIO\" -H \"Tags: $TAGS\" \\\n  --data-binary @- \"$NTFY_URL/$NTFY_TOPIC\")\n[ \"$code\" = \"200\" ] && exit 0\n\n# Fallback: ntfy.sh, once, over IPv4 only.\nfcode=$(printf '%s' \"$BODY\" | curl -4 -sS --max-time 15 -o /dev/null -w '%{http_code}' \\\n  -H \"Title: $TITLE\" -H \"Priority: $PRIO\" -H \"Tags: $TAGS,fallback\" \\\n  --data-binary @- \"https://ntfy.sh/$NTFY_TOPIC\")\n[ \"$fcode\" = \"200\" ] && exit 0\nlogger -t notify-phone \"alert NOT delivered: '$TITLE' (HTTP $code, fallback $fcode)\"\nexit 1\n```\n\n`NTFY_URL` is `http://127.0.0.1:2586`, so the normal path never leaves the machine, doesn't go through nginx and isn't rate limited. The token goes to curl through `-H @<(...)`: curl reads the header from a file descriptor, so the token never shows up in the process list where any user on the box could see it with `ps`.\n\nThe fallback adds a `fallback` tag. My phone stays subscribed to the same topic on ntfy.sh too, so if my server's ntfy is down I still hear about it, and the tag tells me which path it took. The real version of this script also logs each failure and exits early with a clear message if the topic or token is missing.\n\nHaving one script is most of the value. When I changed how alerts are delivered, I changed one file, and every check on the server picked it up.\n\nThe apps running on the server took a little longer. Each one had its own few lines of code that POSTed straight to ntfy.sh, which is exactly the \"anything else on the server\" problem from earlier. They now read two environment variables, the server's URL and the same write-only token, and send an `Authorization: Bearer` header. Nothing on the box talks to ntfy.sh anymore except the fallback and the iOS wake-ups.\n\n### Subscribing From The Phone\n\nIn the ntfy app on iPhone:\n\n1. Settings, then **Users**, then **Add user**. Enter the service URL (`https://ntfy.example.com`), the `phone` username and its password.\n2. Subscriptions, then **+**. Enter the topic name, turn on **Use another server** and enter the same URL.\n\nAndroid works the same way (Settings, then Manage users; then + and \"Use another server\"). The web app at the server's own URL works too, after signing in.\n\niOS needs one more piece on the server. Apps can't keep a connection open in the background there, so a self-hosted server can't wake the phone by itself. That's what `upstream-base-url` is for. When a message arrives, my server sends ntfy.sh a `poll_request` that contains the message ID and the SHA-256 of the topic URL, and nothing else. ntfy.sh passes that through Firebase to Apple's push service, and the phone wakes up and fetches the real message from my server. The [docs on iOS instant notifications](https://docs.ntfy.sh/config/#ios-instant-notifications) walk through it. The alert text never goes to ntfy.sh. If the phone can't reach my server in time, it shows \"New message\".\n\nThose poll requests are requests to ntfy.sh, so they count against the same per-address budget as everything else. I run the ntfy service with `RestrictAddressFamilies=AF_INET AF_UNIX` in a systemd drop-in, which leaves it unable to open an IPv6 socket, so its outbound requests go over IPv4. ntfy also has an `upstream-access-token` setting for the case where the upstream poll still hits a limit: it authenticates those requests to ntfy.sh with an account token instead of counting them against your address.\n\n### Testing It\n\nA test from the server:\n\n```bash\necho \"hello from the server\" | sudo /usr/local/sbin/notify-phone \"ntfy test\" default test_tube\n```\n\n`test_tube` is one of ntfy's emoji tags, so the test shows up with a little test tube on it. To test the fallback, stop the service with `sudo systemctl stop ntfy` and send it again. The message should arrive through ntfy.sh with the `fallback` tag, and `journalctl -t notify-phone` shows which path each alert took.","html_content":"<p>My server sends push notifications to my phone when something needs a look: memory running low, a process that stopped answering on its port, a nightly job that failed. For a long time those went through <a href=\"https://ntfy.sh\">ntfy</a>, which is about the nicest way there is to get a message from a shell script onto a phone. You POST some text to a URL and your phone buzzes:</p>\n<figure class=\"code\" data-lang=\"bash\"><figcaption><span class=\"lang\">Shell</span></figcaption><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">curl</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -d</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"Backup job failed\"</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> https://ntfy.sh/your-alert-topic</span></span></code></pre></figure><p>Then one day every alert started coming back <code>429 Too Many Requests</code>. Nothing on my side had changed, and the same request from my Mac went through fine.</p>\n<h2 id=\"what-ntfy-sh-counts\" data-level=\"3\">What ntfy.sh Counts</h2>\n<p>ntfy.sh is free and has no accounts for publishing, so its limits are per visitor, and a visitor is an IP address. The <a href=\"https://docs.ntfy.sh/publish/#limitations\">publishing docs</a> list the daily cap: “On ntfy.sh, the daily message limit is 250.”</p>\n<p>The detail that matters is how ntfy treats IPv6. From the <a href=\"https://docs.ntfy.sh/config/#ipv6-considerations\">server config docs</a>:</p>\n<blockquote>\n<p>By default, rate limiting for IPv6 is done using the <code>/64</code> subnet of the visitor’s IPv6 address. This means that all visitors in the same <code>/64</code> subnet are treated as one visitor.</p>\n</blockquote>\n<p>That’s a sensible default. Home internet providers routinely hand each customer a /64 or bigger, so counting single IPv6 addresses would let anyone dodge the limit by rotating through their own block. An IPv4 address, on the other hand, counts on its own (<code>visitor-prefix-bits-ipv4</code> defaults to 32).</p>\n<h2 id=\"where-the-64-comes-from\" data-level=\"3\">Where The /64 Comes From</h2>\n<p>Cloud providers don’t always work like home providers. DigitalOcean’s <a href=\"https://docs.digitalocean.com/products/networking/ipv6/details/limits/\">IPv6 limits page</a> says a droplet gets “a maximum of 16 addresses (a subnet mask of <code>/124</code>)”. The interface itself is configured with a /64 prefix, but my droplet only owns 16 addresses inside it. The rest of that /64 belongs to other droplets.</p>\n<p>So from ntfy.sh’s point of view, my server and every other droplet in that /64 that talks to ntfy.sh are one visitor sharing one 250 message budget. That shared budget was empty, and my alerts were getting turned away with it.</p>\n<p>Over IPv4, my server is the only one on its address. When a host has both an IPv6 and an IPv4 address and the server can reach both, <code>curl</code> tries IPv6 first, so every alert had been going out over the shared one.</p>\n<h2 id=\"checking-it-yourself\" data-level=\"3\">Checking It Yourself</h2>\n<p>ntfy.sh will tell you how it sees you. <code>GET /v1/account</code> without credentials describes the anonymous visitor you’re being counted as, including messages sent and remaining today. Ask once over each address family and compare:</p>\n<figure class=\"code\" data-lang=\"bash\"><figcaption><span class=\"lang\">Shell</span></figcaption><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">curl</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -6</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -s</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> https://ntfy.sh/v1/account</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> |</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> jq</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> '.limits.basis, .stats'</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">curl</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -4</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -s</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> https://ntfy.sh/v1/account</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> |</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> jq</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> '.limits.basis, .stats'</span></span></code></pre></figure><p><code>basis</code> comes back as <code>&quot;ip&quot;</code>, and <code>stats.messages_remaining</code> is the number to look at. If the IPv6 line is at zero while you’ve barely sent anything, you’re sharing a /64. The daily counter resets at midnight UTC.</p>\n<h2 id=\"the-quick-fix-curl-4\" data-level=\"3\">The Quick Fix: curl -4</h2>\n<p>The fastest fix is one flag. <code>-4</code> makes curl resolve and connect over IPv4 only:</p>\n<figure class=\"code\" data-lang=\"bash\"><figcaption><span class=\"lang\">Shell</span></figcaption><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">curl</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -4</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -d</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"Backup job failed\"</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> https://ntfy.sh/your-alert-topic</span></span></code></pre></figure><p>When I tested it, the same request that got a 429 over IPv6 got a 200 over IPv4. If you use ntfy.sh from a cloud server and want alerts back in five minutes, that’s the change to make.</p>\n<p>It only covers the senders you remember to change, though. Anything else on the server that talks to ntfy.sh (a cron job, an app’s own notification code, a library with its own HTTP client) still spends from whichever budget it lands on, and your important alerts share both budgets with all of it. I wanted my alerts to stop depending on a free public quota at all.</p>\n<h2 id=\"running-my-own-ntfy\" data-level=\"3\">Running My Own ntfy</h2>\n<p>ntfy is a single Go binary with an official apt repository, and it’s the same open-source server that ntfy.sh runs. I installed the package, which comes with a systemd unit and an <code>ntfy</code> user, and kept it on loopback behind nginx. The config that matters, from <code>/etc/ntfy/server.yml</code>:</p>\n<figure class=\"code\" data-lang=\"yaml\"><figcaption><span class=\"file\">/etc/ntfy/server.yml</span><span class=\"lang\">YAML</span></figcaption><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#22863A;--shiki-dark:#85E89D\">base-url</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"https://ntfy.example.com\"</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#22863A;--shiki-dark:#85E89D\">listen-http</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"127.0.0.1:2586\"</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#22863A;--shiki-dark:#85E89D\">behind-proxy</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">true</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"--shiki-light:#6A737D;--shiki-dark:#6A737D\"># Nothing is readable or writable without a user or token.</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#22863A;--shiki-dark:#85E89D\">auth-file</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"/var/lib/ntfy/user.db\"</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#22863A;--shiki-dark:#85E89D\">auth-default-access</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"deny-all\"</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#22863A;--shiki-dark:#85E89D\">enable-signup</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">false</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#22863A;--shiki-dark:#85E89D\">enable-login</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">true</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#22863A;--shiki-dark:#85E89D\">require-login</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">true</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"--shiki-light:#6A737D;--shiki-dark:#6A737D\"># Keep messages for 48 hours so the phone can catch up after being offline.</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#22863A;--shiki-dark:#85E89D\">cache-file</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"/var/cache/ntfy/cache.db\"</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#22863A;--shiki-dark:#85E89D\">cache-duration</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"48h\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"--shiki-light:#6A737D;--shiki-dark:#6A737D\"># iOS instant delivery (more on this below).</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#22863A;--shiki-dark:#85E89D\">upstream-base-url</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"https://ntfy.sh\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"--shiki-light:#6A737D;--shiki-dark:#6A737D\"># Scripts on the server publish over loopback and skip the request limit.</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#22863A;--shiki-dark:#85E89D\">visitor-request-limit-exempt-hosts</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"127.0.0.1,::1\"</span></span></code></pre></figure><p><code>auth-default-access: &quot;deny-all&quot;</code> is the line that makes it private. Out of the box ntfy lets anyone read and write any topic, which is how ntfy.sh works and why its topic names have to be hard to guess. With <code>deny-all</code>, an unknown topic is closed to everyone until you grant access to it.</p>\n<p><code>behind-proxy: true</code> tells ntfy to take the client’s address from <code>X-Forwarded-For</code>. Without it, every request arrives from nginx’s 127.0.0.1 and every phone and browser gets rate limited as one visitor.</p>\n<h2 id=\"users-and-tokens\" data-level=\"3\">Users And Tokens</h2>\n<p>I made three users with the <code>ntfy</code> CLI, each with only what it needs:</p>\n<figure class=\"code\" data-lang=\"bash\"><figcaption><span class=\"lang\">Shell</span></figcaption><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">sudo</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -u</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> ntfy</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> ntfy</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> user</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> add</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> --role=admin</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> admin-user</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">sudo</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -u</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> ntfy</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> ntfy</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> user</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> add</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> publisher</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">sudo</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -u</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> ntfy</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> ntfy</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> user</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> add</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> phone</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">sudo</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -u</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> ntfy</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> ntfy</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> access</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> publisher</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> your-alert-topic</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> write-only</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">sudo</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -u</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> ntfy</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> ntfy</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> access</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> phone</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> your-alert-topic</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> read-only</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">sudo</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -u</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> ntfy</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> ntfy</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> token</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> add</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> --label=server-alerts</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> publisher</span></span></code></pre></figure><p>The scripts on the server never use a password. They use an access token (<code>tk_...</code>) for the <code>publisher</code> user, which can post to the alert topic and can’t read anything. The phone signs in as <code>phone</code>, which can read the topic and can’t post. If either credential leaks, the damage stops at that one ability on that one topic.</p>\n<p>The token lives in a small env file owned by root, with a group for the accounts allowed to send alerts, mode 0640. <code>ntfy user add</code> reads the password from the <code>NTFY_PASSWORD</code> environment variable if it’s set, so it never has to show up in a command line.</p>\n<h2 id=\"the-nginx-side\" data-level=\"3\">The nginx Side</h2>\n<p>nginx terminates TLS and passes everything to the loopback port:</p>\n<figure class=\"code\" data-lang=\"nginx\"><figcaption><span class=\"file\">/etc/nginx/sites-available/ntfy.example.com</span><span class=\"lang\">nginx</span></figcaption><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">server</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">  listen </span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">443</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> ssl;</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">  server_name </span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">ntfy.example.com;</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">  access_log </span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">off</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">;   </span><span style=\"--shiki-light:#6A737D;--shiki-dark:#6A737D\"># topic names are in the URLs</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">  location</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> / </span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">{</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">    proxy_pass </span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">http://127.0.0.1:2586;</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">    proxy_http_version </span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">1.1</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">    proxy_buffering </span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">off</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">;                        </span><span style=\"--shiki-light:#6A737D;--shiki-dark:#6A737D\"># streaming subscriptions</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">    proxy_set_header </span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">Host $http_host;</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">    proxy_set_header </span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">Upgrade $http_upgrade;     </span><span style=\"--shiki-light:#6A737D;--shiki-dark:#6A737D\"># WebSocket subscriptions</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">    proxy_set_header </span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">Connection </span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"upgrade\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">    proxy_set_header </span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">X-Forwarded-For $remote_addr;</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">    proxy_set_header </span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">X-Forwarded-Proto $scheme;</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">    proxy_read_timeout </span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">3m</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">}</span></span></code></pre></figure><p>Two lines are easy to get wrong. <code>proxy_buffering off</code> matters because the web app and some clients hold a connection open and stream messages as they arrive, and a buffering proxy sits on them. And <code>X-Forwarded-For $remote_addr</code> overwrites the header instead of appending to it (which is what <code>$proxy_add_x_forwarded_for</code> does), so a client can’t send its own <code>X-Forwarded-For</code> and pick which visitor ntfy’s rate limiter thinks it is.</p>\n<p>I also turned the access log off for this host. Topic names appear in every URL, and on a private server the topic name is part of the secret.</p>\n<h2 id=\"one-script-for-every-sender\" data-level=\"3\">One Script For Every Sender</h2>\n<p>Everything on the server that wants to buzz my phone calls one script, <code>/usr/local/sbin/notify-phone</code>, with a title as its argument and the body on stdin. It tries my server first and falls back to ntfy.sh over IPv4:</p>\n<figure class=\"code\" data-lang=\"bash\"><figcaption><span class=\"file\">/usr/local/sbin/notify-phone</span><span class=\"lang\">Shell</span></figcaption><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#6A737D;--shiki-dark:#6A737D\">#!/bin/bash</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">set</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -uo</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> pipefail</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">TITLE</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">${1</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">:-</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">server</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> alert</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">}</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">; PRIO</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">${2</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">:-</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">default</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">}</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">; TAGS</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">${3</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">:-</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">warning</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">}</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">.</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> /etc/profile.d/ntfy.sh</span><span style=\"--shiki-light:#6A737D;--shiki-dark:#6A737D\">                 # NTFY_TOPIC, NTFY_URL (not secret)</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">.</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> /etc/notify-phone/publish.env</span><span style=\"--shiki-light:#6A737D;--shiki-dark:#6A737D\">          # NTFY_TOKEN (root:alerts 0640)</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">BODY</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$(</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">head</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -c</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> 3800</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">)                     </span><span style=\"--shiki-light:#6A737D;--shiki-dark:#6A737D\"># ntfy messages max out at 4,096 bytes</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">code</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$(</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">printf</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> '%s'</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$BODY</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> |</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> curl</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -sS</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> --max-time</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> 15</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -o</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> /dev/null</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -w</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> '%{http_code}'</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">  -H</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> @&#x3C;(</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">printf</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> 'Authorization: Bearer %s\\n' \"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$NTFY_TOKEN</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\")</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">  -H</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"Title: </span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$TITLE</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -H</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"Priority: </span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$PRIO</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -H</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"Tags: </span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$TAGS</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">  --data-binary</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> @-</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$NTFY_URL</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">/</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$NTFY_TOPIC</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">[ </span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$code</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> =</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"200\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> ] &#x26;&#x26; </span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">exit</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> 0</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"--shiki-light:#6A737D;--shiki-dark:#6A737D\"># Fallback: ntfy.sh, once, over IPv4 only.</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">fcode</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$(</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">printf</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> '%s'</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$BODY</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> |</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> curl</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -4</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -sS</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> --max-time</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> 15</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -o</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> /dev/null</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -w</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> '%{http_code}'</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">  -H</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"Title: </span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$TITLE</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -H</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"Priority: </span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$PRIO</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -H</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"Tags: </span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$TAGS</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">,fallback\"</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">  --data-binary</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> @-</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"https://ntfy.sh/</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$NTFY_TOPIC</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">[ </span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$fcode</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> =</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"200\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> ] &#x26;&#x26; </span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">exit</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> 0</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">logger</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -t</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> notify-phone</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"alert NOT delivered: '</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$TITLE</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">' (HTTP </span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$code</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">, fallback </span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$fcode</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">)\"</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">exit</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> 1</span></span></code></pre></figure><p><code>NTFY_URL</code> is <code>http://127.0.0.1:2586</code>, so the normal path never leaves the machine, doesn’t go through nginx and isn’t rate limited. The token goes to curl through <code>-H @&lt;(...)</code>: curl reads the header from a file descriptor, so the token never shows up in the process list where any user on the box could see it with <code>ps</code>.</p>\n<p>The fallback adds a <code>fallback</code> tag. My phone stays subscribed to the same topic on ntfy.sh too, so if my server’s ntfy is down I still hear about it, and the tag tells me which path it took. The real version of this script also logs each failure and exits early with a clear message if the topic or token is missing.</p>\n<p>Having one script is most of the value. When I changed how alerts are delivered, I changed one file, and every check on the server picked it up.</p>\n<p>The apps running on the server took a little longer. Each one had its own few lines of code that POSTed straight to ntfy.sh, which is exactly the “anything else on the server” problem from earlier. They now read two environment variables, the server’s URL and the same write-only token, and send an <code>Authorization: Bearer</code> header. Nothing on the box talks to ntfy.sh anymore except the fallback and the iOS wake-ups.</p>\n<h2 id=\"subscribing-from-the-phone\" data-level=\"3\">Subscribing From The Phone</h2>\n<p>In the ntfy app on iPhone:</p>\n<ol>\n<li>Settings, then <strong>Users</strong>, then <strong>Add user</strong>. Enter the service URL (<code>https://ntfy.example.com</code>), the <code>phone</code> username and its password.</li>\n<li>Subscriptions, then <strong>+</strong>. Enter the topic name, turn on <strong>Use another server</strong> and enter the same URL.</li>\n</ol>\n<p>Android works the same way (Settings, then Manage users; then + and “Use another server”). The web app at the server’s own URL works too, after signing in.</p>\n<p>iOS needs one more piece on the server. Apps can’t keep a connection open in the background there, so a self-hosted server can’t wake the phone by itself. That’s what <code>upstream-base-url</code> is for. When a message arrives, my server sends ntfy.sh a <code>poll_request</code> that contains the message ID and the SHA-256 of the topic URL, and nothing else. ntfy.sh passes that through Firebase to Apple’s push service, and the phone wakes up and fetches the real message from my server. The <a href=\"https://docs.ntfy.sh/config/#ios-instant-notifications\">docs on iOS instant notifications</a> walk through it. The alert text never goes to ntfy.sh. If the phone can’t reach my server in time, it shows “New message”.</p>\n<p>Those poll requests are requests to ntfy.sh, so they count against the same per-address budget as everything else. I run the ntfy service with <code>RestrictAddressFamilies=AF_INET AF_UNIX</code> in a systemd drop-in, which leaves it unable to open an IPv6 socket, so its outbound requests go over IPv4. ntfy also has an <code>upstream-access-token</code> setting for the case where the upstream poll still hits a limit: it authenticates those requests to ntfy.sh with an account token instead of counting them against your address.</p>\n<h2 id=\"testing-it\" data-level=\"3\">Testing It</h2>\n<p>A test from the server:</p>\n<figure class=\"code\" data-lang=\"bash\"><figcaption><span class=\"lang\">Shell</span></figcaption><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">echo</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"hello from the server\"</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> |</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> sudo</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> /usr/local/sbin/notify-phone</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"ntfy test\"</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> default</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> test_tube</span></span></code></pre></figure><p><code>test_tube</code> is one of ntfy’s emoji tags, so the test shows up with a little test tube on it. To test the fallback, stop the service with <code>sudo systemctl stop ntfy</code> and send it again. The message should arrive through ntfy.sh with the <code>fallback</code> tag, and <code>journalctl -t notify-phone</code> shows which path each alert took.</p>","created_at":"2026-10-30T13:00:00.000Z","updated_at":"2026-10-30T13:00:00.000Z"}