Self-hosting ntfy on Coolify, from install to your first alert

Run your own ntfy server on Coolify: deploy the one-click template, lock it down, create accounts and topics, subscribe from your phone, and point Butters at it.

10 min read
A blue server with a yellow padlock connected by a line with a coral lightning bolt to a blue phone with a ringing yellow bell above it

ntfy is a small open source server that turns an HTTP request into a notification on your phone. The public server at ntfy.sh is free and works well. But it's someone else's server, and on it, anyone who guesses your topic name can read your alerts.

If you already run Coolify, hosting your own copy takes about fifteen minutes. This post walks through the whole thing: deploying it, locking it down, creating accounts, picking topics, setting up the phone apps, and connecting Butters so a notified event actually buzzes your pocket.

If you're still deciding between ntfy, web push and Slack, read Your phone as a pager, for free first.

What you need

  • A Coolify server that's reachable from the internet.
  • A subdomain, like ntfy.example.com, with an A record pointing at that server.
  • The ntfy app on your phone, or a browser. We'll get to those.

One thing to settle before you start: your ntfy server needs a public address. Butters refuses to send to private and local addresses, so that a destination can't be used to poke around the network it runs in. That rules out localhost, 10.x, 192.168.x, and the 100.64.0.0/10 range, which is where Tailscale addresses live. A ntfy server you can only reach over your tailnet won't work as a Butters destination. A normal public subdomain behind Coolify's proxy is fine.

Step 1: Deploy the template

Coolify ships a one-click ntfy service. In your project, click + New, pick your server and environment, and search for ntfy in the list of services.

Before you hit deploy, set the domain. Coolify fills in a generated sslip.io address by default. Replace it with https://ntfy.example.com. Coolify's proxy will get a Let's Encrypt certificate for it, and the template already passes that URL to ntfy as its base URL.

Don't deploy yet. The defaults need changing first.

Step 2: Fix the defaults before the first boot

The template is set up for an open server: anyone can sign up, and anyone can read or write any topic. That's the right default for a demo and the wrong one for your alerts. Open the service's Environment Variables and change these:

NTFY_AUTH_DEFAULT_ACCESS=deny-all
NTFY_ENABLE_SIGNUP=false
NTFY_ENABLE_LOGIN=true

deny-all means nobody can read or publish unless you've given them access. Turning off signup means the only accounts are the ones you create. Leaving login on lets you sign in to the web app.

While you're there, look at three more:

  • The SMTP variables. The template fills them with placeholder values like smtp.your-domain.de. Clear them unless you want ntfy to send email.
  • `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` defaults to 100 in the template. Every notification Butters sends comes from the same account, so a busy project can hit that cap by the afternoon. Raise it, or clear the value to remove the limit.
  • `NTFY_UPSTREAM_BASE_URL` is set to https://ntfy.sh. Leave it. The iOS app needs it, and the phone section below explains why.

NTFY_BEHIND_PROXY is already true, which is what you want behind Coolify's proxy. It makes ntfy rate-limit by the real client IP instead of the proxy's.

Now deploy. When it's up, open https://ntfy.example.com/v1/health in a browser. You should see {"healthy":true}.

Step 3: Create your accounts

You need two accounts: one for you, and one for Butters. Keeping them separate means you can revoke Butters without locking yourself out.

Open the service in Coolify, go to Terminal, and connect to the ntfy container. The ntfy command is already on the path, and it picks up the database location from the container's environment.

Create yourself as an admin. Admins can read and write every topic, so you won't need any extra rules:

ntfy user add --role=admin roger

It asks for a password twice. Then create a regular user for Butters:

ntfy user add butters

That account can't do anything yet, because the default is deny-all. We'll give it access once we've picked topic names.

(If you'd rather keep everything in Coolify's environment variables, ntfy can also read users from NTFY_AUTH_USERS, with bcrypt hashes you generate with ntfy user hash. It's handy for rebuilds, but note that removing a user from that variable deletes them on the next restart. The terminal is simpler for a first setup.)

Step 4: Pick topics and grant access

There's no "create topic" step in ntfy. A topic exists as soon as someone publishes to it or subscribes to it. What you do control is who's allowed to touch it.

A prefix keeps this tidy. Put everything Butters sends under butters-, for example butters-prod for things that should wake you up and butters-daily for things that can wait. Then give the Butters account write-only access to that prefix:

ntfy access butters 'butters-*' write-only

Write-only is the right level here. Butters only publishes, so there's no reason for its credential to be able to read your topics back. The quotes stop your shell from expanding the *.

Check the result with:

ntfy access

You should see your admin account with read-write on everything, butters with write-only on butters-*, and no access for anyone else.

Step 5: Make a token for Butters

Butters can log in with a username and password, but an access token is better. You can revoke it without changing a password, and it has a label so you know what it's for later.

ntfy token add --label="Butters" butters

It prints a token that starts with tk_. Copy it somewhere safe for a minute. You'll paste it into Butters in step 7.

One thing to know: an ntfy token currently carries all of its user's permissions. That's another reason to give Butters its own write-only account instead of a token on your admin account.

Step 6: Set up your phone

The ntfy app is free on both platforms. Get it from Google Play or F-Droid on Android, or the App Store on iPhone.

The setup is the same on both:

  1. Open the app's settings and add a user for your server: https://ntfy.example.com, with your admin username and password. The app then signs in to that server automatically.
  2. Tap + to subscribe to a topic. Turn on the option to use another server, enter https://ntfy.example.com, and type the topic name, like butters-prod.
  3. Repeat for each topic you want on this phone.

You can also set your server as the default in the app's settings, so you don't have to type it every time.

The two platforms deliver messages differently, and it's worth knowing how.

On Android, the app holds an open connection to your server. For self-hosted servers this happens even in the Google Play version, since that version only uses Google's push service for ntfy.sh. You'll see a permanent "subscription service" notification, which is what keeps the connection alive. You can hide it in Android's notification settings for the app. Android also gives each ntfy priority its own notification channel, so you can give priority 5 a sound that gets through Do Not Disturb and leave the rest quiet.

On iPhone, apps can't keep a connection open in the background, so iOS needs Apple's push service. That's what NTFY_UPSTREAM_BASE_URL is for. When a message arrives, your server sends a small poll request to ntfy.sh that contains only the message ID and a hash of the topic URL. ntfy.sh passes that to Apple, your phone wakes up, and it fetches the actual message from your server. The contents of your alerts never go through ntfy.sh. Without that setting, notifications on iOS still arrive, but they can be hours late.

If you'd rather not install anything, the web app at https://ntfy.example.com works too. Log in, subscribe to the same topics, and allow browser notifications.

Step 7: Connect Butters

In Butters, open your project's Settings, find the ntfy section, and click Add. Fill in the form:

  • Friendly Name: anything, like "Phone, prod". It's only shown in the list.
  • ntfy Topic: butters-prod. Letters, digits, dashes and underscores, up to 64 characters.
  • Server URL: https://ntfy.example.com. If you paste the full topic URL, Butters keeps just the origin, so that's fine too.
  • Priority: 1 to 5. Use 5 for anything that should wake you up.
  • Authentication Method: Access token, then paste the tk_ token from step 5.
  • Icon URL: optional. A PNG or JPEG URL that ntfy shows as the notification icon.

Save it with Add destination, then click Test on the new row. Your phone should buzz within a second or two. If it doesn't, the row shows the error, and the next section explains the common ones.

Now send a real event with notify set. With the CLI :

butters push \
  --project $PROJECT \
  --category deploys \
  --title "Deploy failed" \
  --description "**api** on main, build step" \
  --icon "🚨" \
  --notify

Or with curl:

curl -X POST https://app.getbutters.com/api/events \
  -H "Authorization: Bearer $BUTTERS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"project":"'$PROJECT'","category":"deploys","title":"Deploy failed","notify":true}'

The notification's title is the event title, and the body is the description, with bold and links rendered. The category shows as a tag, and if the event has a link, tapping the notification opens it. Events without notify stay in the feed and never reach ntfy. Get pinged for the right events, and only those has a rule of thumb for when to set it.

When the test fails

Butters shows the last error on each destination, and most of them point straight at the problem.

  • 403 Forbidden. The server was reached but the account isn't allowed to write to that topic. Check that the topic matches the prefix you granted in step 4, and run ntfy access again.
  • 401 Unauthorized. The token is wrong or was removed. Run ntfy token list butters to see what's there, and make a new one if needed.
  • Server redirected. You entered http:// and Coolify redirected it to https://. Butters won't follow redirects on a publish, so use the https:// URL.
  • Server URL points at a private or local address. The domain resolves to a private IP. Point it at the server's public IP. See "What you need" above.
  • No response within 5s. Butters waits five seconds, then gives up on that event. Check that the container is running and that /v1/health still answers.
  • 429 Too Many Requests. You hit the daily message limit from step 2. Raise it and redeploy.

If Test says it sent but nothing appears, the problem is on the phone. Check that you're subscribed to exactly the same topic on the same server, and on iPhone, check that NTFY_UPSTREAM_BASE_URL is still set.

Worth doing afterwards

Back up the volumes. Your accounts, tokens and access rules live in the ntfy-db volume. The message cache is in ntfy-cache. Losing the cache only loses recent history. Losing the database means doing steps 3 to 5 again. Add the database volume to your Coolify backups.

Remember that Butters sends each event once. If your ntfy server is down when an event comes in, that notification is missed. It's still in the feed, and the error shows on the destination, but there's no retry. If an alert really matters, pair ntfy with web push or a Slack channel so one outage doesn't hide it.

Keep the Butters account small. If you add more senders later, like a backup script or a home server, give each one its own user, prefix and token. When one gets compromised or retired, you revoke one token and nothing else changes.

That's the whole setup: one container, two accounts, one rule, one token. Your alerts now go from Butters to a server you own and straight to your phone.