Can I self-host Pushover?

YES · ONE COMMAND— setup effort 1 of 4

YES — it's called ntfy. It takes one prompt, a 256 MB VPS, and about 9 minutes. There is no like-for-like Pushover price to quote for this swap, so this page quotes none.

Why people pay for Pushover

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Pushover sells a boring reliability promise: a message from a script reaches the phone that is already in your pocket, through Apple and Google's push networks, without you running a broker or nursing an app that falls asleep. The bill is small, and the value is that the path works when you are not looking at a dashboard.

Pushover plans and list prices
PlanList priceWhat it buys
Unlimitedthe plan this page prices againstquote only$4.99 once per platform (iPhone/iPad, Android, Desktop). Delivery after purchase has no monthly fee; the separate Pushover for Teams product bills $5 per user per month.

Vendor list prices in USD, read from the pricing page on 2026-08-07 · confidence: medium

Replaced by ntfy

One project, named before the prompt, so you know what you are about to install.

HTTP publish-subscribe push notifications to your phone, with no per-message bill.

The closest HTTP-shaped replacement: publish with curl, subscribe on phone apps, no per-message invoice. You take on the server, TLS, and locking down open topics.

What else we looked at

Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.

  1. ntfyour pickONE COMMAND— setup effort 1 of 4

    HTTP publish-subscribe push notifications to your phone, with no per-message bill.

    The closest HTTP-shaped replacement: publish with curl, subscribe on phone apps, no per-message invoice. You take on the server, TLS, and locking down open topics.

  2. GotifyONE COMMAND— setup effort 1 of 4

    A self-hosted push notification server with a simple REST API and its own Android app.

    A self-hosted push server with its own Android app and a simple REST API. Fewer client platforms than ntfy, MIT licensed, one container.

The swap

You're paying

Pushover

no list price to quote

is replaced by

You'd run

ntfy

ONE COMMAND · ~9 min to running · 256 MB RAM

Pushover Unlimited · no like-for-like list price on the vendor's page, so this page quotes none

Before you start

RAM floor
256 MBfloor from upstream docs — not measured by us yet
Disk
2 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~9 minunder 10 minutes, through the first backup

The prompt

Two paths to the same ntfy: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

303 lines · 13,035 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install ntfy 2.27.0 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server.

Say three things before anything is installed. One: this becomes a private push broker. The
default on ntfy.sh is open topics; this install sets auth-default-access to deny-all, so a
stranger who guesses a topic name cannot publish or subscribe. Two: users are created on the
CLI with `ntfy user add`, not in a browser signup wizard. Three: iOS instant delivery on a
self-hosted server needs `upstream-base-url` pointed at ntfy.sh's APNS bridge, which is a
third-party hop; without it, iOS notifications arrive delayed. This install leaves that off
until the user opts in.

ntfy needs 256 MB of RAM available and 2 GB free on /srv. The image publishes amd64 and arm64.
Measure all four:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 256 MB or free disk is under 2 GB, print both numbers and stop. Do not
install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify a
hostname that does not resolve, and NTFY_BASE_URL must be that same public URL.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/ntfy /srv/ntfy/backups /srv/ntfy/cache /srv/ntfy/auth
ls -la /srv/ntfy
```

Assert: `ls -la` shows `backups`, `cache` and `auth`, owned by the login user. `cache` holds
the message cache SQLite file and attachments. `auth` holds the user database that deny-all
auth writes into. There is no empty `data/` directory in this install.

## 3. Secrets

One secret: the password for the admin account you will create in step 7. Generate it on the
server. Do not print it, do not repeat it in your summary, and do not put it in any log line.

```bash
umask 077
cat > /srv/ntfy/.env <<EOF
NTFY_BASE_URL=https://<DOMAIN>
NTFY_USERNAME=admin
NTFY_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 /srv/ntfy/.env
umask 022
ls -l /srv/ntfy/.env
```

Assert: the file exists with mode `-rw-------`. Replace `<DOMAIN>` on the first line with the
real hostname before running the block. Tell the user their username is `admin`, that they read
the password once with `sudo grep NTFY_PASSWORD /srv/ntfy/.env`, and that they should put it in
their password manager now. NTFY_BASE_URL is the public https URL phones and curl will use; a
wrong value breaks attachment links and the web UI's idea of where it lives.

## 4. compose.yml

```bash
cat > /srv/ntfy/compose.yml <<'EOF'
# ntfy · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://docs.ntfy.sh/install/#docker
#   configuration ...... https://docs.ntfy.sh/config/
#   access control ..... https://docs.ntfy.sh/config/#access-control
#   behind a proxy ..... https://docs.ntfy.sh/config/#behind-a-proxy-tls-etc
#
# One container. Serve listens on port 80 inside the image. Auth is closed by
# default (deny-all): anonymous publish and subscribe are refused until a user
# is created with `ntfy user add`. Cache and auth live on separate mounts so a
# backup can name each path. NTFY_BASE_URL comes from .env and must be the
# public https URL (this file is the VPS path). behind-proxy is true because
# Caddy on the host terminates TLS. Digest read from Docker Hub on 2026-08-07;
# the manifest list covers amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  ntfy:
    image: binwiederhier/ntfy:v2.27.0@sha256:f2419f405127afa868f10985c1a41449e673477cee1eb19994339a5ae8b592e7
    container_name: ntfy
    restart: unless-stopped
    command: ["serve"]
    env_file: /srv/ntfy/.env
    environment:
      # Public URL phones and curl use. Set in .env as https://your.hostname.
      NTFY_BASE_URL: ${NTFY_BASE_URL}
      NTFY_CACHE_FILE: /var/cache/ntfy/cache.db
      NTFY_ATTACHMENT_CACHE_DIR: /var/cache/ntfy/attachments
      # Auth database is created on first start when this path is set.
      NTFY_AUTH_FILE: /var/lib/ntfy/user.db
      # Private instance: no anonymous read or write to any topic.
      NTFY_AUTH_DEFAULT_ACCESS: deny-all
      # Web UI login form; users themselves are still created on the CLI.
      NTFY_ENABLE_LOGIN: "true"
      # Rate limits must use X-Forwarded-For; without this every client is Caddy.
      NTFY_BEHIND_PROXY: "true"
    volumes:
      - /srv/ntfy/cache:/var/cache/ntfy
      - /srv/ntfy/auth:/var/lib/ntfy
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8200.
      - "127.0.0.1:8200:80"
EOF
cd /srv/ntfy && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, two bind mounts, no
database container. The auth database appears under `auth/` after first start.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-ntfy
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
DOMAIN_HOST=<DOMAIN>
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" <<'EOF' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
# ntfy · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.ntfy.sh/config/#nginxapachecaddy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project. Long-lived
# subscribe streams and WebSockets use Caddy's default reverse_proxy behaviour.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8200 is the loopback port compose publishes; it is never in the firewall.
	reverse_proxy 127.0.0.1:8200
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Set `DOMAIN_HOST` to the real hostname (not the literal `<DOMAIN>`) before the `sed` runs.
Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-ntfy, reload, and report what it objected to. Caddy requests the
certificate on the first request and renews it on its own.

## 6. Firewall

Two ports open, both Caddy's. These are idempotent, so on a box Prompt Zero configured they
change nothing:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, and 443/udp
is HTTP/3. 8200 stays closed because compose binds it to 127.0.0.1. Assert:
`ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule
mentioning 8200.

## 7. Start and verify

```bash
cd /srv/ntfy
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/v1/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/v1/health; echo
```

Assert: the loop ends printing `200` and the health body is healthy JSON. If the container
exits, run `docker compose logs --tail 40 ntfy` and stop.

Create the admin user non-interactively. Upstream accepts `NTFY_PASSWORD` in the environment
for scripts, so there is no interactive password prompt:

```bash
cd /srv/ntfy
NTFY_PASS=$(grep -E '^NTFY_PASSWORD=' .env | cut -d= -f2-)
NTFY_USER=$(grep -E '^NTFY_USERNAME=' .env | cut -d= -f2-)
docker compose exec -T -e NTFY_PASSWORD="$NTFY_PASS" ntfy ntfy user add --role=admin "$NTFY_USER"
docker compose exec -T ntfy ntfy user list
```

Assert: `user list` shows the admin user with role admin. Do not print `$NTFY_PASS`.

Close the instance and prove it. Anonymous publish must be denied; authenticated publish must
succeed:

```bash
unauth=$(curl -sS -o /dev/null -w '%{http_code}' -d 'probe' https://<DOMAIN>/caniselfhostit-probe)
echo "anonymous publish: $unauth"
auth=$(curl -sS -o /dev/null -w '%{http_code}' -u "${NTFY_USER}:${NTFY_PASS}" -d 'install ok' https://<DOMAIN>/caniselfhostit-probe)
echo "authenticated publish: $auth"
```

Assert: anonymous prints `403` and authenticated prints `200`. If anonymous is `200`, auth did
not load (check NTFY_AUTH_FILE and NTFY_AUTH_DEFAULT_ACCESS). If authenticated is not `200`, the
user was not created or the password in `.env` does not match. A running container is not
success; those two status codes are.

Publish handoff for the phone: tell the user to open the ntfy app (Android or iOS), add
https://<DOMAIN> as a custom server, sign in with username `admin` and the password from
`.env`, and subscribe to a topic they choose (for example `alerts`). Then publish once more
with curl using that topic name so the phone shows the message.

STOP: tell the user to confirm two things back to you: that they can log into the web UI at
https://<DOMAIN> with the admin account, and that an unauthenticated curl publish still returns
403. Do not continue until they confirm.

## 8. First backup and restore

One archive: the cache, the auth database, the compose file, `.env` (the admin password), and
the live Caddy site block. Take it now, before there is a month of topics to lose.

```bash
cd /srv/ntfy
docker compose stop
sudo tar -czf /srv/ntfy/backups/ntfy-$(date +%F).tar.gz -C /srv/ntfy cache auth compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/ntfy/backups/
```

Assert: the archive exists and is non-empty. Print its size. Downtime is a few seconds; the
container is stopped on purpose so SQLite files are not copied mid-write.

A backup on the same disk as the data is not a backup. Run this one from the user's machine,
not the server:

```bash
mkdir -p ~/backups/ntfy
scp vps:/srv/ntfy/backups/*.tar.gz ~/backups/ntfy/
```

To restore: `docker compose down`, remove `cache` and `auth`, recreate those directories as in
step 2, untar the archive back into /srv/ntfy (which restores `.env` and compose.yml), put the
Caddy block back if that is what was lost, then `docker compose up -d`. Tell the user which
half matters: `auth/` is every account, and `.env` is the only place the generated password
lives. Losing either without the other is a lockout.

## 9. Updating later

New versions are listed at https://github.com/binwiederhier/ntfy/releases. The release tag and
the image tag are the same string, so release `v2.28.0` is image tag `v2.28.0`. Take a backup
first, then edit the image line in /srv/ntfy/compose.yml to the new tag and its digest:

```bash
cd /srv/ntfy
docker compose pull
docker compose up -d
docker compose logs --tail 30 ntfy
```

Watch that log until it settles, then re-run step 7's health check and the anonymous `403`
assert before calling the update done.

## 10. What will probably go wrong

You will publish a test message, the phone will stay silent, and you will assume ntfy is broken.
I did that on a self-hosted box with the iOS app. Android was fine; iOS was minutes late or
never instant. Instant delivery on iOS is not pure self-host: the app expects your server to
forward a wake-up through ntfy.sh's APNS path when you set `NTFY_UPSTREAM_BASE_URL=https://ntfy.sh`
in the compose environment and recreate the container. Without that line, iOS still works, but
it is delayed polling, not a real-time push. If you add it, say out loud that wake-ups now
touch ntfy.sh even though message content stays on your server. The other common miss is
forgetting basic auth on curl: after deny-all, a bare `curl -d hi https://host/topic` is
supposed to return 403. That is the product working, not a failure.

## 11. Out of scope

- Do not set `NTFY_UPSTREAM_BASE_URL` unless the user has heard the iOS APNS trade-off and asked
  for instant iOS delivery.
- Do not enable `NTFY_ENABLE_SIGNUP`. New accounts are created with `ntfy user add` on the
  server, not by strangers on the web UI.
- Do not open port 8200 in the firewall. Caddy is the only public listener.
- Do not switch the cache to PostgreSQL. SQLite in the `cache` mount is the choice here.
- Do not configure Firebase credentials for FCM. Self-hosted Android delivery uses the app's
  own path or UnifiedPush; this install does not run Google's push on your behalf.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing ntfy 2.27.0 on a VPS where Prompt Zero is done: `ssh vps` works, Docker and
Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a step
says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at the
box.

Read these three before step 1. This install is a private push broker: auth-default-access is
deny-all, so a stranger who guesses a topic cannot publish. Users are created with
`ntfy user add` on the server CLI, not in a browser signup form. Instant iOS delivery on a
self-hosted server needs an upstream APNS bridge through ntfy.sh if you want it; this install
leaves that off until you opt in.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `256` MB available, at least `2` G free, `amd64` or `arm64`, and your
server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and NTFY_BASE_URL must be that same public URL.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/ntfy /srv/ntfy/backups /srv/ntfy/cache /srv/ntfy/auth
ls -la /srv/ntfy
```

You should see: `backups`, `cache` and `auth` under /srv/ntfy, owned by your login user.

If you do not: re-run the `install -d` line. `cache` holds the message SQLite file and
attachments. `auth` holds the user database. There is no empty `data/` directory in this install.

## 3. Secrets

One secret: the password for the admin account you create in step 7. Generate it on the server.
Do not paste it into this chat. Replace `<DOMAIN>` before you run the block.

```bash
umask 077
cat > /srv/ntfy/.env <<EOF
NTFY_BASE_URL=https://<DOMAIN>
NTFY_USERNAME=admin
NTFY_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 /srv/ntfy/.env
umask 022
ls -l /srv/ntfy/.env
```

You should see: mode `-rw-------` and your own username twice.

If you do not: run `chmod 600 /srv/ntfy/.env` and carry on. Your username is `admin`. Read the
password once with `sudo grep NTFY_PASSWORD /srv/ntfy/.env` and put it in your password manager.
Do not paste that file into this chat window.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/ntfy/compose.yml <<'EOF'
# ntfy · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://docs.ntfy.sh/install/#docker
#   configuration ...... https://docs.ntfy.sh/config/
#   access control ..... https://docs.ntfy.sh/config/#access-control
#   behind a proxy ..... https://docs.ntfy.sh/config/#behind-a-proxy-tls-etc
#
# One container. Serve listens on port 80 inside the image. Auth is closed by
# default (deny-all): anonymous publish and subscribe are refused until a user
# is created with `ntfy user add`. Cache and auth live on separate mounts so a
# backup can name each path. NTFY_BASE_URL comes from .env and must be the
# public https URL (this file is the VPS path). behind-proxy is true because
# Caddy on the host terminates TLS. Digest read from Docker Hub on 2026-08-07;
# the manifest list covers amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  ntfy:
    image: binwiederhier/ntfy:v2.27.0@sha256:f2419f405127afa868f10985c1a41449e673477cee1eb19994339a5ae8b592e7
    container_name: ntfy
    restart: unless-stopped
    command: ["serve"]
    env_file: /srv/ntfy/.env
    environment:
      # Public URL phones and curl use. Set in .env as https://your.hostname.
      NTFY_BASE_URL: ${NTFY_BASE_URL}
      NTFY_CACHE_FILE: /var/cache/ntfy/cache.db
      NTFY_ATTACHMENT_CACHE_DIR: /var/cache/ntfy/attachments
      # Auth database is created on first start when this path is set.
      NTFY_AUTH_FILE: /var/lib/ntfy/user.db
      # Private instance: no anonymous read or write to any topic.
      NTFY_AUTH_DEFAULT_ACCESS: deny-all
      # Web UI login form; users themselves are still created on the CLI.
      NTFY_ENABLE_LOGIN: "true"
      # Rate limits must use X-Forwarded-For; without this every client is Caddy.
      NTFY_BEHIND_PROXY: "true"
    volumes:
      - /srv/ntfy/cache:/var/cache/ntfy
      - /srv/ntfy/auth:/var/lib/ntfy
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8200.
      - "127.0.0.1:8200:80"
EOF
cd /srv/ntfy && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `services must be a mapping` means the indentation was lost. Run
`rm /srv/ntfy/compose.yml` and paste again in one go.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Set `DOMAIN_HOST` to
your real hostname (not the literal string `<DOMAIN>`) before you paste. The first line takes a
copy, because a syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-ntfy
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
DOMAIN_HOST=<DOMAIN>
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" <<'EOF' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
# ntfy · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.ntfy.sh/config/#nginxapachecaddy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project. Long-lived
# subscribe streams and WebSockets use Caddy's default reverse_proxy behaviour.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8200 is the loopback port compose publishes; it is never in the firewall.
	reverse_proxy 127.0.0.1:8200
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-ntfy /etc/caddy/Caddyfile`, reload, and
paste again. Caddy requests the certificate on the first request to the hostname and renews it
on its own.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8200`.

If you do not: delete anything for `8200` with `sudo ufw delete allow 8200`. It is bound to
127.0.0.1 by the compose file, so a rule for it would cover traffic that cannot arrive.

## 7. Start and verify

```bash
cd /srv/ntfy
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/v1/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/v1/health; echo
```

You should see: the loop reaching `200`, then healthy JSON from `/v1/health`.

If you do not: run `docker compose logs --tail 40 ntfy` and read the last lines before
continuing. A container that exits on its own usually means the auth path is not writable or
the image pull failed; check that `/srv/ntfy/auth` and `/srv/ntfy/cache` exist and are owned
by your login user. A 502 from Caddy with a running container points at step 5 (wrong port or
a site block that never reloaded).

Create the admin user. Upstream accepts `NTFY_PASSWORD` in the environment so the command is
non-interactive. There is no browser "create account" step anywhere in this product: if you
only open the web UI and look for a signup form, you will not find a working open registration
path, because enable-signup is off and deny-all is on.

```bash
cd /srv/ntfy
NTFY_PASS=$(grep -E '^NTFY_PASSWORD=' .env | cut -d= -f2-)
NTFY_USER=$(grep -E '^NTFY_USERNAME=' .env | cut -d= -f2-)
docker compose exec -T -e NTFY_PASSWORD="$NTFY_PASS" ntfy ntfy user add --role=admin "$NTFY_USER"
docker compose exec -T ntfy ntfy user list
```

You should see: a line for your admin user with role admin. Do not paste `$NTFY_PASS` into this
chat.

Prove the door is closed, then that your account can still publish:

```bash
unauth=$(curl -sS -o /dev/null -w '%{http_code}' -d 'probe' https://<DOMAIN>/caniselfhostit-probe)
echo "anonymous publish: $unauth"
auth=$(curl -sS -o /dev/null -w '%{http_code}' -u "${NTFY_USER}:${NTFY_PASS}" -d 'install ok' https://<DOMAIN>/caniselfhostit-probe)
echo "authenticated publish: $auth"
```

You should see: `anonymous publish: 403` and `authenticated publish: 200`.

If you do not: a `200` on anonymous means auth did not load; check that `NTFY_AUTH_FILE` and
`NTFY_AUTH_DEFAULT_ACCESS` are in the compose file and recreate the container. A non-200 on
authenticated means the user was not created or the password does not match `.env`.

Publish handoff: open the ntfy app on a phone, add https://<DOMAIN> as a custom server, sign in
as admin with the password from `.env`, subscribe to a topic (for example `alerts`), then:

```bash
curl -u "${NTFY_USER}:${NTFY_PASS}" -d 'phone test' https://<DOMAIN>/alerts
```

The phone should show that message. Instant iOS delivery needs
`NTFY_UPSTREAM_BASE_URL=https://ntfy.sh` in the compose environment (a third-party APNS hop);
without it, iOS is delayed. Android does not need that bridge for self-host. If the phone is on
cellular and the hostname resolves, this is the real loop the product exists for: script on
the server, toast on the handset.

Access tokens are optional later: `docker compose exec -T ntfy ntfy token add admin` creates a
token you can put in scripts so the password never appears in cron lines. Tokens still grant
full account access today, so treat them like the password.

Open https://<DOMAIN> in a private window, log in, and confirm that a bare
`curl -d hi https://<DOMAIN>/topic` still returns 403. Do not continue until they confirm.

## 8. First backup and restore

One archive: the cache, the auth database, compose.yml, `.env`, and the live Caddyfile.

```bash
cd /srv/ntfy
docker compose stop
sudo tar -czf /srv/ntfy/backups/ntfy-$(date +%F).tar.gz -C /srv/ntfy cache auth compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/ntfy/backups/
```

You should see: one non-empty file. Downtime is a few seconds; the container is stopped so
SQLite is not copied mid-write.

If you do not: an archive of about 100 bytes means `tar` found none of the paths. Run
`tar -tzf` on it and read what it actually contains.

A backup on the same disk as the data is not a backup. On your own machine:

```bash
mkdir -p ~/backups/ntfy
scp vps:/srv/ntfy/backups/*.tar.gz ~/backups/ntfy/
```

To restore: `docker compose down`, remove `cache` and `auth`, recreate those directories, untar
into /srv/ntfy (restores `.env` and compose.yml), restore the Caddy block if needed, then
`docker compose up -d`. `auth/` is every account; `.env` is the only place the generated
password lives. Losing either without the other is a lockout.

Prove the restore while the only thing at risk is a test topic: after `docker compose up -d`,
wait for `/v1/health` to return 200, then re-run the anonymous publish curl and confirm it is
still `403`, and the authenticated publish is still `200` with the password from the restored
`.env`. If authenticated fails after a restore that included `auth/` but not `.env`, you have
the classic lockout: recreate the admin user only if you are willing to lose the old password
mapping, otherwise find the `.env` from the off-box copy of the archive.

## 9. Updating later

New versions are listed at https://github.com/binwiederhier/ntfy/releases. The release tag and
the image tag are the same string. Take a backup first, then edit the `image:` line in
/srv/ntfy/compose.yml to the new tag and its digest.

```bash
cd /srv/ntfy
docker compose pull
docker compose up -d
docker compose logs --tail 30 ntfy
```

You should see: the server starting, no restart loop. Re-run `/v1/health` and the anonymous
`403` check from step 7 before you call the update done.

## 10. What will probably go wrong

You will publish a test message, the phone will stay silent, and you will assume ntfy is broken.
I did that on a self-hosted box with the iOS app. Android was fine; iOS was minutes late. Instant
delivery on iOS is not pure self-host: set `NTFY_UPSTREAM_BASE_URL=https://ntfy.sh` only if you
accept that wake-ups touch ntfy.sh. Without that line, iOS still works as delayed polling. The
other common miss is a bare `curl -d hi https://host/topic` returning 403 after deny-all. That
is the product working. Use `-u admin:password` or an access token.

A second failure mode is rate limiting that treats every visitor as one IP. If you forget
`NTFY_BEHIND_PROXY=true` while Caddy terminates TLS, ntfy sees only Caddy's address and the
whole world shares one visitor bucket. This compose already sets behind-proxy; if you strip it
during an experiment, put it back before you call the install healthy under load.

A third is restoring only `cache/` and forgetting `.env`. After a restore the container starts,
deny-all still holds, and you no longer know the password that matches `auth/`. Always restore
`.env` with the auth database.

## 11. Out of scope

- Do not set `NTFY_UPSTREAM_BASE_URL` unless you want the iOS APNS bridge and accept the hop.
- Do not enable signup. New accounts are `ntfy user add` on the server only.
- Do not open port 8200 in the firewall. Caddy is the only public listener.
- Do not switch the cache to PostgreSQL. SQLite in the `cache` mount is the choice here.
- Do not configure Firebase credentials for FCM on this install.
- Do not leave auth-default-access at read-write on a public hostname. That recreates the open
  ntfy.sh shape on your own box, and anyone who learns a topic name can publish to your phone.

281 lines · 12,670 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install ntfy 2.27.0 under ~/selfhost/ntfy, answering at http://localhost:8200.

## 1. Preflight

Say this to the user before step 2 runs, because it decides whether they want this install at
all. A push broker exists so a phone can get a message from a script, and this one answers at
http://localhost:8200, which means this computer and nothing else. A phone on cellular cannot
reach localhost, and notifications only leave this box while Docker is running and the machine
is awake. What they get is a private broker for scripts on this desk and for apps that can
reach this host on the LAN if they later rebind the port (this install does not).

Also say: this install is closed. Auth default is deny-all. Users are created with
`ntfy user add` on the CLI, not in a browser wizard. There is no open topic for strangers.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. ntfy needs 256 MB of RAM available and 2 GB
free on the home disk, and the image publishes amd64 and arm64. Every branch prints free
memory, so one floor covers all three; on macOS and Windows it is the host's, and Docker Desktop
takes its allocation out of it. If available RAM is under 256 MB or free disk is under 2 GB,
print both numbers and stop. Do not install and hope.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/ntfy/cache ~/selfhost/ntfy/auth ~/selfhost/ntfy/backups
ls -la ~/selfhost/ntfy
```

Assert: `ls -la` shows `cache`, `auth` and `backups`. `cache` is the message cache and
attachments. `auth` is the user database. There is no empty `data/` directory.

## 4. Secrets

One secret: the password for the admin account you will create in step 7. Generate it here,
print neither it nor a summary that includes it, and keep it out of any log line.

```bash
umask 077
cat > ~/selfhost/ntfy/.env <<'EOF'
NTFY_BASE_URL=http://localhost:8200
NTFY_USERNAME=admin
NTFY_PASSWORD=PLACEHOLDER
EOF
# Replace PLACEHOLDER with a real secret without printing it:
pass=$(openssl rand -base64 24)
# portable in-place replace for the password line only
tmp=$(mktemp)
awk -v p="$pass" 'BEGIN{FS=OFS="="} $1=="NTFY_PASSWORD"{$2=p} {print}' ~/selfhost/ntfy/.env > "$tmp" && mv "$tmp" ~/selfhost/ntfy/.env
chmod 600 ~/selfhost/ntfy/.env
umask 022
unset pass
ls -l ~/selfhost/ntfy/.env
```

Assert: the file exists with mode `-rw-------` (on Windows mode bits are advisory). Tell the
user their username is `admin` and they read the password with
`grep NTFY_PASSWORD ~/selfhost/ntfy/.env`. NTFY_BASE_URL is the loopback URL for this path.

## 5. compose.yml

```bash
cat > ~/selfhost/ntfy/compose.yml <<'EOF'
# ntfy · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://docs.ntfy.sh/install/#docker
#   configuration ...... https://docs.ntfy.sh/config/
#   access control ..... https://docs.ntfy.sh/config/#access-control
#
# One container on the computer you are sitting at. Paths are relative to
# ~/selfhost/ntfy/. Auth is closed by default (deny-all). NTFY_BASE_URL is the
# loopback URL on this machine. behind-proxy is off: nothing sits in front.
# Digest read from Docker Hub on 2026-08-07; the list covers amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  ntfy:
    image: binwiederhier/ntfy:v2.27.0@sha256:f2419f405127afa868f10985c1a41449e673477cee1eb19994339a5ae8b592e7
    container_name: ntfy
    restart: unless-stopped
    command: ["serve"]
    env_file: .env
    environment:
      NTFY_BASE_URL: ${NTFY_BASE_URL}
      NTFY_CACHE_FILE: /var/cache/ntfy/cache.db
      NTFY_ATTACHMENT_CACHE_DIR: /var/cache/ntfy/attachments
      NTFY_AUTH_FILE: /var/lib/ntfy/user.db
      NTFY_AUTH_DEFAULT_ACCESS: deny-all
      NTFY_ENABLE_LOGIN: "true"
    volumes:
      - ./cache:/var/cache/ntfy
      - ./auth:/var/lib/ntfy
    ports:
      # Loopback only: no other device on the wifi can reach 8200.
      - "127.0.0.1:8200:80"
EOF
cd ~/selfhost/ntfy && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, two bind mounts.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision. There is no hostname
to resolve. A certificate attests a public name and nothing here has one; browsers
treat http://localhost as a secure context anyway. Nothing is published beyond loopback, so no
port needs closing.

8200 is bound to 127.0.0.1, this computer only. The user's phone cannot reach it, nor a laptop on
the same wifi, nor anyone on the internet. Confirm the binding:

```bash
grep -c '"127.0.0.1:' ~/selfhost/ntfy/compose.yml
```

Assert: the count is `1`. A phone test of this install requires a later rebind or a tunnel,
which this path does not do.

## 7. Start and verify

```bash
cd ~/selfhost/ntfy
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8200/v1/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS http://localhost:8200/v1/health; echo
```

Assert: the loop ends printing `200`. If `port is already allocated` came back, find what holds
8200 (`lsof -nP -iTCP:8200 -sTCP:LISTEN`, `ss -ltnp | grep 8200` on Linux,
`netstat -ano | findstr :8200` on Windows) and stop until the user frees it.

Create the admin user, then prove deny-all:

```bash
cd ~/selfhost/ntfy
NTFY_PASS=$(grep -E '^NTFY_PASSWORD=' .env | cut -d= -f2-)
NTFY_USER=$(grep -E '^NTFY_USERNAME=' .env | cut -d= -f2-)
docker compose exec -T -e NTFY_PASSWORD="$NTFY_PASS" ntfy ntfy user add --role=admin "$NTFY_USER"
docker compose exec -T ntfy ntfy user list
unauth=$(curl -sS -o /dev/null -w '%{http_code}' -d 'probe' http://localhost:8200/caniselfhostit-probe)
echo "anonymous publish: $unauth"
auth=$(curl -sS -o /dev/null -w '%{http_code}' -u "${NTFY_USER}:${NTFY_PASS}" -d 'install ok' http://localhost:8200/caniselfhostit-probe)
echo "authenticated publish: $auth"
```

Assert: `user list` shows admin, anonymous prints `403`, authenticated prints `200`. Do not
print `$NTFY_PASS`. A running container is not success.

STOP: tell the user to open http://localhost:8200, log in with admin and the password from `.env`, and confirm that an unauthenticated curl publish still returns 403. Do not continue until they confirm.

## 8. First backup and restore

One archive: the cache, the auth database, the compose file and `.env`.

```bash
cd ~/selfhost/ntfy
docker compose stop
tar -C ~/selfhost/ntfy -czf ~/selfhost/ntfy/backups/ntfy-$(date +%F).tar.gz cache auth compose.yml .env
docker compose start
ls -lh ~/selfhost/ntfy/backups/
```

Assert: the archive exists and is non-empty. Print its size. Downtime is a few seconds; the
container is stopped on purpose so SQLite files are not copied mid-write.

That archive sits on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a folder
their sync service watches or a USB stick, and copy it there with `cp`. In Git Bash a Windows
drive is written `/d/Backups`, not `D:\Backups`; confirm it exists before copying. Assert: the
user confirms the filename is listed there. If they have nowhere, say plainly that this install
has no backup.

To restore: `cd ~/selfhost/ntfy`, `docker compose down`, `rm -rf cache auth`, untar the archive
there (which restores `.env` and compose.yml), then `docker compose up -d`. Tell the user:
`auth/` is every account and `.env` holds the generated password. Losing either is a lockout.

## 9. Updating later

New versions are listed at https://github.com/binwiederhier/ntfy/releases. The release tag and
the image tag are the same string. Take a backup first, then edit the image line in
~/selfhost/ntfy/compose.yml to the new tag and its digest:

```bash
cd ~/selfhost/ntfy
docker compose pull
docker compose up -d
docker compose logs --tail 30 ntfy
```

Watch that log until it settles, then re-run step 7's health check and the anonymous `403`
assert before calling the update done.

## 10. What will probably go wrong

You will try to subscribe from a phone on the same wifi, and nothing will connect. This install
binds 8200 to 127.0.0.1 on purpose, so the phone never sees the port. That is not a bug in ntfy;
it is the local path's boundary. Use curl on this machine for the publish handoff, or move to
the VPS path when a phone needs a public hostname. The other miss is assuming iOS instant push
works without an upstream APNS bridge: on a pure localhost install it will not, and this path
does not set `NTFY_UPSTREAM_BASE_URL`.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8200 to 0.0.0.0 so a phone on the wifi can publish without auth context you
  understand. deny-all still applies, but the attack surface grows.
- Do not enable signup. New accounts are `ntfy user add` only.
- Do not set Firebase credentials or an upstream base URL on this laptop path.
compose.local.ymlthe services, pinned · local layout33 lines

authored from upstream docs, never pasted · 1,402 bytes

# ntfy · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://docs.ntfy.sh/install/#docker
#   configuration ...... https://docs.ntfy.sh/config/
#   access control ..... https://docs.ntfy.sh/config/#access-control
#
# One container on the computer you are sitting at. Paths are relative to
# ~/selfhost/ntfy/. Auth is closed by default (deny-all). NTFY_BASE_URL is the
# loopback URL on this machine. behind-proxy is off: nothing sits in front.
# Digest read from Docker Hub on 2026-08-07; the list covers amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  ntfy:
    image: binwiederhier/ntfy:v2.27.0@sha256:f2419f405127afa868f10985c1a41449e673477cee1eb19994339a5ae8b592e7
    container_name: ntfy
    restart: unless-stopped
    command: ["serve"]
    env_file: .env
    environment:
      NTFY_BASE_URL: ${NTFY_BASE_URL}
      NTFY_CACHE_FILE: /var/cache/ntfy/cache.db
      NTFY_ATTACHMENT_CACHE_DIR: /var/cache/ntfy/attachments
      NTFY_AUTH_FILE: /var/lib/ntfy/user.db
      NTFY_AUTH_DEFAULT_ACCESS: deny-all
      NTFY_ENABLE_LOGIN: "true"
    volumes:
      - ./cache:/var/cache/ntfy
      - ./auth:/var/lib/ntfy
    ports:
      # Loopback only: no other device on the wifi can reach 8200.
      - "127.0.0.1:8200:80"

agent-readable mirror: /self-host/pushover.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned43 lines

authored from upstream docs, never pasted · 2,104 bytes

# ntfy · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://docs.ntfy.sh/install/#docker
#   configuration ...... https://docs.ntfy.sh/config/
#   access control ..... https://docs.ntfy.sh/config/#access-control
#   behind a proxy ..... https://docs.ntfy.sh/config/#behind-a-proxy-tls-etc
#
# One container. Serve listens on port 80 inside the image. Auth is closed by
# default (deny-all): anonymous publish and subscribe are refused until a user
# is created with `ntfy user add`. Cache and auth live on separate mounts so a
# backup can name each path. NTFY_BASE_URL comes from .env and must be the
# public https URL (this file is the VPS path). behind-proxy is true because
# Caddy on the host terminates TLS. Digest read from Docker Hub on 2026-08-07;
# the manifest list covers amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  ntfy:
    image: binwiederhier/ntfy:v2.27.0@sha256:f2419f405127afa868f10985c1a41449e673477cee1eb19994339a5ae8b592e7
    container_name: ntfy
    restart: unless-stopped
    command: ["serve"]
    env_file: /srv/ntfy/.env
    environment:
      # Public URL phones and curl use. Set in .env as https://your.hostname.
      NTFY_BASE_URL: ${NTFY_BASE_URL}
      NTFY_CACHE_FILE: /var/cache/ntfy/cache.db
      NTFY_ATTACHMENT_CACHE_DIR: /var/cache/ntfy/attachments
      # Auth database is created on first start when this path is set.
      NTFY_AUTH_FILE: /var/lib/ntfy/user.db
      # Private instance: no anonymous read or write to any topic.
      NTFY_AUTH_DEFAULT_ACCESS: deny-all
      # Web UI login form; users themselves are still created on the CLI.
      NTFY_ENABLE_LOGIN: "true"
      # Rate limits must use X-Forwarded-For; without this every client is Caddy.
      NTFY_BEHIND_PROXY: "true"
    volumes:
      - /srv/ntfy/cache:/var/cache/ntfy
      - /srv/ntfy/auth:/var/lib/ntfy
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8200.
      - "127.0.0.1:8200:80"
Caddyfilethe hostname and TLS25 lines

authored from upstream docs, never pasted · 838 bytes

# ntfy · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.ntfy.sh/config/#nginxapachecaddy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project. Long-lived
# subscribe streams and WebSockets use Caddy's default reverse_proxy behaviour.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8200 is the loopback port compose publishes; it is never in the firewall.
	reverse_proxy 127.0.0.1:8200
}
install.shthe same install, no agent152 lines

authored from upstream docs, never pasted · 6,832 bytes

#!/usr/bin/env bash
# ntfy · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=ntfy.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.ntfy.sh/install/#docker
#   https://docs.ntfy.sh/config/
#   https://docs.ntfy.sh/config/#access-control
#
# One secret is generated here: the password for the admin user. It goes into
# /srv/ntfy/.env with mode 600 and is never printed. The admin account is then
# created with NTFY_PASSWORD=... ntfy user add (upstream's non-interactive path).
# Auth default is deny-all, so anonymous publish is refused until that user exists.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/ntfy}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_USER="${ADMIN_USER:-admin}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. ntfy.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 256 ] || die "only ${avail_mb} MB of RAM available; this install wants 256 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 2 ] || die "only ${avail_gb} GB free on /srv; this install wants 2 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups" "$APP_DIR/cache" "$APP_DIR/auth"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the admin password, on the server ---------------------------
#
# Username is admin (override with ADMIN_USER). Read the password later with
#   sudo grep NTFY_PASSWORD /srv/ntfy/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		NTFY_BASE_URL=https://${DOMAIN_HOST}
		NTFY_USERNAME=${ADMIN_USER}
		NTFY_PASSWORD=$(openssl rand -base64 24)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-ntfy"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and 8200 is not one of them -------------------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8200 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it, create the admin user, close the door ----------------------

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/v1/health"
for _ in $(seq 1 30); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/v1/health" || true)"
	[ "$code" = "200" ] && break
	sleep 5
done
[ "${code:-}" = "200" ] || die "/v1/health answered ${code:-nothing}. Check: docker compose logs --tail 40 ntfy"

# Create the admin account non-interactively (upstream supports NTFY_PASSWORD=).
# Skip if the user already exists (re-run of this script).
NTFY_PASS="$(grep -E '^NTFY_PASSWORD=' "$APP_DIR/.env" | cut -d= -f2-)"
NTFY_USER="$(grep -E '^NTFY_USERNAME=' "$APP_DIR/.env" | cut -d= -f2-)"
[ -n "$NTFY_PASS" ] || die "NTFY_PASSWORD missing from $APP_DIR/.env"
if ! docker compose exec -T ntfy ntfy user list 2>/dev/null | grep -q "user ${NTFY_USER} "; then
	docker compose exec -T -e "NTFY_PASSWORD=${NTFY_PASS}" ntfy ntfy user add --role=admin "${NTFY_USER}" \
		|| die "failed to create admin user ${NTFY_USER}"
fi

# Anonymous publish must be refused.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' -d 'probe' "https://${DOMAIN_HOST}/caniselfhostit-probe" || true)"
[ "$unauth" = "403" ] || die "anonymous publish returned ${unauth}, not 403. Auth is not closed."

# Authenticated publish must succeed.
auth="$(curl -sS -o /dev/null -w '%{http_code}' -u "${NTFY_USER}:${NTFY_PASS}" -d 'install ok' "https://${DOMAIN_HOST}/caniselfhostit-probe" || true)"
[ "$auth" = "200" ] || die "authenticated publish returned ${auth}, not 200. Check: docker compose logs --tail 40 ntfy"

# --- 7. The first backup, before day one ends --------------------------------
#
# Stopped on purpose: the SQLite cache and auth files should not be copied
# mid-write. Downtime is a few seconds.

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/ntfy-${STAMP}.tar.gz" -C "$APP_DIR" cache auth compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/ntfy-${STAMP}.tar.gz" ] || die "the backup archive is empty"

cat <<-DONE

	ntfy is answering at https://${DOMAIN_HOST}/

	  1. Your username is ${NTFY_USER}. Read the password once with
	       sudo grep NTFY_PASSWORD $APP_DIR/.env
	     put it in your password manager, and do not paste it anywhere else.
	     It was not printed here. There is no browser setup wizard: users are
	     created with ntfy user add on the server.
	  2. Anonymous publish is denied (deny-all). Publish with
	       curl -u ${NTFY_USER}:\$PASSWORD -d "hello" https://${DOMAIN_HOST}/your-topic
	  3. On a phone, open the ntfy app, add this server as a base URL, log in
	     with the same account, and subscribe to a topic. iOS instant delivery
	     needs NTFY_UPSTREAM_BASE_URL=https://ntfy.sh (routes through ntfy.sh
	     APNS); without it, iOS delivery is delayed. Android uses the app's own
	     connection or a UnifiedPush distributor and needs no bridge.
	  4. First backup written to $APP_DIR/backups (cache, auth, .env, compose,
	     live Caddyfile). It is on the same disk as the data, which is not a
	     backup. Copy it somewhere else tonight.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Pushover.

  • This install is closed: auth-default-access is deny-all and anonymous publish returns 403. Your admin account is created on the CLI with ntfy user add, not in a browser signup form. Anyone who can guess a topic still cannot write to it without credentials.
  • You own the push path to phones. Point the Android or iOS app at your base URL and log in. Instant iOS delivery routes through ntfy.sh's APNS bridge when you set upstream-base-url; without that, iOS is delayed. That is a deliberate third-party hop, not free magic.
  • You own the backups. The cache database, the auth user database, and .env (the admin password) are the state. Lose auth without a backup and you rebuild accounts from scratch.
  • There is no SLA and no pager on call except you. If the box is down, so are the alerts that depended on it.
  • Rate limits and attachment size caps are yours to set. Defaults that felt fine on ntfy.sh can surprise you under a noisy script.

Where this came from

“ntfy (pronounced "notify") is a simple HTTP-based pub-sub notification service.”

  • Upstream publishes a multi-arch Docker image and documents a single-service install that serves HTTP on port 80 inside the container with command serve. source
  • Access control is enabled by setting auth-file and auth-default-access; deny-all makes a private instance where anonymous publish and subscribe are refused until users and ACLs exist. source
  • Users can be created non-interactively with NTFY_PASSWORD=... ntfy user add, and admin role grants read-write on all topics. source
  • Behind a reverse proxy, behind-proxy must be true so rate limiting uses X-Forwarded-For instead of treating every client as the proxy. source
  • iOS instant notifications for self-hosted servers forward through an upstream base URL (typically ntfy.sh APNS); without it, delivery is delayed. source
  • Caddy obtains and renews TLS certificates automatically for any public hostname named in the Caddyfile. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Pushover?

    Not Pushover itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is ntfy. HTTP publish-subscribe push notifications to your phone, with no per-message bill. The install is one command: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 9 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Pushover?

    ntfy. HTTP publish-subscribe push notifications to your phone, with no per-message bill. The closest HTTP-shaped replacement: publish with curl, subscribe on phone apps, no per-message invoice. You take on the server, TLS, and locking down open topics. Also evaluated and ranked below it: Gotify. The prompt on this page installs ntfy only. ntfy is Apache-2.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Pushover?

    256 MB of RAM and 2 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. ntfy itself is free and Apache-2.0-licensed; the bill is the server, plus a domain you probably already own. There is no like-for-like Pushover list price behind this swap, so this page does not invent a savings figure.

  • How hard is it really?

    ONE COMMAND — under 10 minutes. The rule that produced that verdict: one container, no database, no outside integration, at most one secret. Nothing to negotiate with anyone else, nothing to back up separately, at most one secret to generate. This is the case where the compose file honestly is the whole install. The tier is derived from seven countable facts about the ntfy install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run ntfy on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same ntfy on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Notifications only fire while this computer is awake, so a message you send after the lid closes is not delivered until it wakes, and a phone on the cellular network cannot reach localhost. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.