---
title: "Self-Hosted ntfy And The Shared IPv6 Quota Behind My 429s"
url: https://deallocatedobjects.com/posts/self-hosted-ntfy-and-the-shared-ipv6-quota-behind-my-429s
author: Jake Marsh
published: 2026-10-30
updated: 2026-10-30
topics: ["Web", "Xcode & Tools"]
description: "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, which is about the nicest way there is to get a message from a shell script onto a phone."
---

# Self-Hosted ntfy And The Shared IPv6 Quota Behind My 429s

By Jake Marsh, October 30, 2026. HTML: https://deallocatedobjects.com/posts/self-hosted-ntfy-and-the-shared-ipv6-quota-behind-my-429s

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:

```bash
curl -d "Backup job failed" https://ntfy.sh/your-alert-topic
```

Then 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.

### What ntfy.sh Counts

ntfy.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."

The detail that matters is how ntfy treats IPv6. From the [server config docs](https://docs.ntfy.sh/config/#ipv6-considerations):

> 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.

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 (`visitor-prefix-bits-ipv4` defaults to 32).

### Where The /64 Comes From

Cloud 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.

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.

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, `curl` tries IPv6 first, so every alert had been going out over the shared one.

### Checking It Yourself

ntfy.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:

```bash
curl -6 -s https://ntfy.sh/v1/account | jq '.limits.basis, .stats'
curl -4 -s https://ntfy.sh/v1/account | jq '.limits.basis, .stats'
```

`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.

### The Quick Fix: curl -4

The fastest fix is one flag. `-4` makes curl resolve and connect over IPv4 only:

```bash
curl -4 -d "Backup job failed" https://ntfy.sh/your-alert-topic
```

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.

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.

### Running My Own ntfy

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 `ntfy` user, and kept it on loopback behind nginx. The config that matters, from `/etc/ntfy/server.yml`:

```yaml title="/etc/ntfy/server.yml"
base-url: "https://ntfy.example.com"
listen-http: "127.0.0.1:2586"
behind-proxy: true

# Nothing is readable or writable without a user or token.
auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all"
enable-signup: false
enable-login: true
require-login: true

# Keep messages for 48 hours so the phone can catch up after being offline.
cache-file: "/var/cache/ntfy/cache.db"
cache-duration: "48h"

# iOS instant delivery (more on this below).
upstream-base-url: "https://ntfy.sh"

# Scripts on the server publish over loopback and skip the request limit.
visitor-request-limit-exempt-hosts: "127.0.0.1,::1"
```

`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.

`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.

### Users And Tokens

I made three users with the `ntfy` CLI, each with only what it needs:

```bash
sudo -u ntfy ntfy user add --role=admin admin-user
sudo -u ntfy ntfy user add publisher
sudo -u ntfy ntfy user add phone

sudo -u ntfy ntfy access publisher your-alert-topic write-only
sudo -u ntfy ntfy access phone your-alert-topic read-only

sudo -u ntfy ntfy token add --label=server-alerts publisher
```

The 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.

The 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.

### The nginx Side

nginx terminates TLS and passes everything to the loopback port:

```nginx title="/etc/nginx/sites-available/ntfy.example.com"
server {
  listen 443 ssl;
  server_name ntfy.example.com;
  access_log off;   # topic names are in the URLs

  location / {
    proxy_pass http://127.0.0.1:2586;
    proxy_http_version 1.1;
    proxy_buffering off;                        # streaming subscriptions
    proxy_set_header Host $http_host;
    proxy_set_header Upgrade $http_upgrade;     # WebSocket subscriptions
    proxy_set_header Connection "upgrade";
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_read_timeout 3m;
  }
}
```

Two 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.

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.

### One Script For Every Sender

Everything 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:

```bash title="/usr/local/sbin/notify-phone"
#!/bin/bash
set -uo pipefail
TITLE="${1:-server alert}"; PRIO="${2:-default}"; TAGS="${3:-warning}"
. /etc/profile.d/ntfy.sh                 # NTFY_TOPIC, NTFY_URL (not secret)
. /etc/notify-phone/publish.env          # NTFY_TOKEN (root:alerts 0640)
BODY=$(head -c 3800)                     # ntfy messages max out at 4,096 bytes

code=$(printf '%s' "$BODY" | curl -sS --max-time 15 -o /dev/null -w '%{http_code}' \
  -H @<(printf 'Authorization: Bearer %s\n' "$NTFY_TOKEN") \
  -H "Title: $TITLE" -H "Priority: $PRIO" -H "Tags: $TAGS" \
  --data-binary @- "$NTFY_URL/$NTFY_TOPIC")
[ "$code" = "200" ] && exit 0

# Fallback: ntfy.sh, once, over IPv4 only.
fcode=$(printf '%s' "$BODY" | curl -4 -sS --max-time 15 -o /dev/null -w '%{http_code}' \
  -H "Title: $TITLE" -H "Priority: $PRIO" -H "Tags: $TAGS,fallback" \
  --data-binary @- "https://ntfy.sh/$NTFY_TOPIC")
[ "$fcode" = "200" ] && exit 0
logger -t notify-phone "alert NOT delivered: '$TITLE' (HTTP $code, fallback $fcode)"
exit 1
```

`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`.

The 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.

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.

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 `Authorization: Bearer` header. Nothing on the box talks to ntfy.sh anymore except the fallback and the iOS wake-ups.

### Subscribing From The Phone

In the ntfy app on iPhone:

1. Settings, then **Users**, then **Add user**. Enter the service URL (`https://ntfy.example.com`), the `phone` username and its password.
2. Subscriptions, then **+**. Enter the topic name, turn on **Use another server** and enter the same URL.

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.

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 `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".

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 `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.

### Testing It

A test from the server:

```bash
echo "hello from the server" | sudo /usr/local/sbin/notify-phone "ntfy test" default test_tube
```

`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.
