Self-hosting push notifications

This assumes you already have SSH access to a server and Tailscale installed on it. If you don't have Tailscale set up yet, see step 6 of adding a private RSS reader first — nothing else from that guide is required here. Unlike the RSS reader, this doesn't touch your public web server, your domain, or its TLS certificate at all; it's reachable only through Tailscale, which handles its own certs independently.

ntfy is a small, self-hostable push notification service. Any script or program on any device can send a message to a topic with one HTTP request, and every device subscribed to that topic gets it instantly. No OpenBSD package exists for it, and no official binary either — this builds it from source, using a build procedure a user named Zaraki worked out and shared in a GitHub issue, since ntfy's own install docs don't mention OpenBSD at all.

Reasoning: this replaces cron jobs and scripts failing silently in the background. A weekly backup script, for instance, can now tell you directly whether it worked.

A script at the end does all of this. Read it before running it.

Contents:

1. Install build dependencies
2. Build ntfy from source
3. Create a service user and directories
4. Configure ntfy
5. Create the rc.d service
6. Install Tailscale
7. Publish it privately
8. Read it on your phone
9. The script

1. Install build dependencies

There's no OpenBSD package for ntfy. Building it needs Go, git, and sqlite3:

📋
pkg_add go git sqlite3

2. Build ntfy from source

Clone the repository and build it. The mkdir/touch lines are a workaround for a known quirk: the build expects certain doc and web-UI files to exist, even if you don't plan to use them.

📋
cd /root
git clone https://github.com/binwiederhier/ntfy.git
cd ntfy
mkdir -p dist/ntfy_openbsd_amd64 server/docs server/site
touch server/docs/index.html server/site/app.html
CGO_ENABLED=1 go build -o dist/ntfy_openbsd_amd64/ntfy -tags sqlite_omit_load_extension,osusergo,netgo -ldflags "-linkmode=external -extldflags=-static -s -w"

This compiles a real amount of Go and C code. On a small instance, expect it to take several minutes — that's normal, not stuck. If you want to confirm it's alive rather than hung, check from a second session with ps aux | grep go.

Install the binary:

📋
mv dist/ntfy_openbsd_amd64/ntfy /usr/local/bin
chown root:bin /usr/local/bin/ntfy
chmod 755 /usr/local/bin/ntfy

Set that last permission explicitly. A stray umask earlier in your shell session can leave the binary owner-only executable, which fails silently until you go check the logs.

3. Create a service user and directories

📋
useradd -c 'ntfy server' -d /var/empty -s /sbin/nologin _ntfy
mkdir -p /etc/ntfy /var/cache/ntfy/attachments /var/db/ntfy
chown -R _ntfy /var/cache/ntfy /var/db/ntfy
chmod 750 /var/cache/ntfy /var/cache/ntfy/attachments /var/db/ntfy
chmod 755 /etc/ntfy

That last line matters and is easy to get wrong: if /etc/ntfy itself is only owner-readable, the _ntfy user can't traverse into it, even if the config file inside has correct permissions of its own. The directory needs to be world-traversable; the file inside it can stay locked down.

4. Configure ntfy

📋
cat > /etc/ntfy/server.yml <
listen-http: "127.0.0.1:8091"
cache-file: "/var/cache/ntfy/cache.db"
attachment-cache-dir: "/var/cache/ntfy/attachments"
EOF
chown _ntfy /etc/ntfy/server.yml
chmod 600 /etc/ntfy/server.yml

base-url is optional — it only affects links inside notification payloads and the web UI, not delivery. Add it later once you know your tailnet address, if you care.

5. Create the rc.d service

📋
cat > /etc/rc.d/ntfy <
#!/bin/ksh
daemon="/usr/local/bin/ntfy"
daemon_flags="serve --config /etc/ntfy/server.yml"
daemon_user="_ntfy"
daemon_logger="daemon.info"
 
. /etc/rc.d/rc.subr
 
rc_bg="YES"
rc_cmd $1
EOF
chmod +x /etc/rc.d/ntfy
rcctl enable ntfy
rcctl start ntfy
rcctl check ntfy

Should say ntfy(ok). If it doesn't, this is the step most likely to trip you up: OpenBSD's rc.subr uses daemon to identify and track the running process. Put your arguments there instead of in daemon_flags, and rc.subr can't match the process it just started — it fails immediately, every time, regardless of whether the rest of the setup is correct. Arguments belong in daemon_flags, never appended to daemon itself.

If it's still failing, check /var/log/daemon for the actual error, and run the binary in the foreground once to see it directly:

📋
/usr/local/bin/ntfy serve --config /etc/ntfy/server.yml

6. Install Tailscale

Skip this if it's already running from a previous setup.

📋
pkg_add tailscale
rcctl enable tailscaled
rcctl start tailscaled
tailscale up

It prints a login URL. Open it, approve the device.

7. Publish it privately

This does not touch your public web server, or any port actually open to the internet:

📋
tailscale serve --bg --https=8443 http://127.0.0.1:8091
tailscale serve status

Use a different port than any other private service already running on this box. A given hostname's default port (443) can only point at one thing at a time.

Test it locally first:

📋
curl -d "test message" http://127.0.0.1:8091/sometopicname

8. Read it on your phone

Install the ntfy app. Instead of the default ntfy.sh server, add your own — the .ts.net address from the previous step. Subscribe to any topic name.

Treat the topic name as a secret, not a label. On the public ntfy.sh this matters because anyone who knows a topic can read and publish to it; self-hosted and Tailscale-only, the stakes are lower, but it costs nothing to pick something unguessable anyway: openssl rand -hex 16.

Any script that can reach the server can now notify you — curl -d "message" http://127.0.0.1:8091/yourtopic from the server itself, or the equivalent over the tailnet address from any other device you own.

9. The script

Steps 1 through 7, automated. Run as root on a server with SSH access and, ideally, Tailscale already installed:

📋
ftp https://nevrast.xyz/ntfy.sh
sh ntfy.sh

It pauses where a step needs you: picking a publish port, the Tailscale login if it isn't already set up. Read it before running it, the same as anything else you pipe into a shell.

Source is also on GitHub, alongside the RSS reader script.

Email me with questions or fixes.

← back home