Every outage of my Jellyfin server starts a flood of text messages: “hey is the movie thing broken again?” I’m the status page. At 11pm I’m also asleep. The ntfy and Uptime Kuma pair below fixed this, but only after I moved both of them off my own hardware.
Before moving ntfy and Kuma to a VPS, I ran Uptime Kuma and my ntfy server on a local Docker VM, the same Docker VM hosting half the services they were watching. When that VM died or my internet went out, the monitor and the alert to my phone died with it. The lab failed silently. Except for the text messages asking what happened.
The fix was one cheap VPS running two containers. ntfy pushes to my phone. Uptime Kuma watches the real public path from outside the house and publishes a self-hosted status page my family and friends can check without texting me. So here’s the whole build. The Uptime Kuma Compose file. The Caddy config that keeps the admin dashboard off the internet while the status page stays public. ntfy’s deny-all auth model. The backups almost nobody sets up. And the tests that prove any of it actually works.
I moved both services onto a RackNerd VPS with 5 vCPU, 5.9 GB of RAM (way more than you need for this) and Docker 29.6.2 on 2026-07-16, running louislam/uptime-kuma:2 (2.4.0 at the time) and binwiederhier/ntfy:v2 (2.26.0). Every config block below is the sanitized version of what’s running there right now, along with the two failures that cost me the most time: a silent 403 that hid a dead alert path for a few days, and a Caddy reload that hung for about a half hour.
Why ntfy and Uptime Kuma Belong Outside Your Lab
A monitor that shares a failure domain with the thing it monitors can’t accurately report on it. That’s the whole point here, and it’s why “I already run Uptime Kuma” isn’t an answer.
If Kuma and your notification server both run on the same hypervisor, the same VM, the same power strip, or behind the same ISP connection, they’re one failure away from useless at the exact moment you need them. Power cut, failed PVE node, a WireGuard tunnel that never comes back after a reboot: in all three cases the monitor is dead too, and the silence looks identical to everything being fine.
Put both containers on a VPS in someone else’s data center and they share fate only with your public front door. Lose power at your house, VPS keeps probing, phone still buzzes.
The second payoff is where you’re watching from. A check against https://flix.example.com/ from outside exercises public DNS, the VPS reverse proxy, the tunnel back to the lab, and the backend service in one request. A LAN-side check against http://192.168.1.4:8096 exercises none of those four. It stays green while every one of them is broken.
Two Audiences, Two Very Different Outputs
There are two definitions of “visibility” in play here, and it helps to keep them apart:
- You get push notifications on your phone. Immediate, detailed, noisy on purpose.
- Your users (the four people on your Jellyfin) get a URL. Passive, deliberately vague, no app and no login. They can check for themselves whether everything’s working, even at 3am.
The cost is one small VPS, a domain you probably already own, and two containers that together use well under a gigabyte of RAM.
Now the trade-off: this design gives up LAN-vantage monitoring. When I retired the internal instance I deliberately didn’t recreate its per-host up/down checks. Three outside-in monitors are my complete set. Per-host LAN monitoring is a blind spot I chose to live with. I’d rather say that out loud than pretend otherwise.
The Architecture, Before You Type Anything
Here’s what this looks like. Phones and browsers hit Caddy on the VPS at port 443. Caddy proxies to two loopback-bound containers: 127.0.0.1:3001 for Uptime Kuma and 127.0.0.1:3080 for ntfy. Kuma’s monitors go to your public hostnames, the same way any browser would. Kuma’s notifications go to ntfy over the Docker network and never touch the internet at all.
Two public names:
status.example.com, the status page, served through a strict path allowlist.ntfy.example.com, the full ntfy API, protected by auth rather than by path rules.
Kuma’s admin dashboard is never published anywhere. It lives on the same port as the status page, and the only way in is via an SSH tunnel.
Prerequisites: a VPS with Docker installed, a domain whose DNS you control, Caddy on the VPS, and ports 80 and 443 open. You also need a WireGuard tunnel back to the lab if you want to monitor anything that isn’t publicly exposed. Be clear with yourself about that last one. The first two monitors in this build work fine without a tunnel. The third one doesn’t. No tunnel means you’re building the two-monitor version of this guide. If you haven’t built a WireGuard tunnel before, my WireGuard VPS to homelab guide covers it, and the Nginx Proxy Manager to Caddy migration post covers the reverse proxy side.
The Uptime Kuma Docker Compose File That Keeps Admin Off the Internet
Both services live in one compose project. I keep mine at /docker/compose.yml with app data under /docker/<app>.
services:
uptime-kuma:
image: louislam/uptime-kuma:2
container_name: uptime-kuma
restart: unless-stopped
ports:
- "127.0.0.1:3001:3001"
volumes:
- ./uptime-kuma/data:/app/data
ntfy:
image: binwiederhier/ntfy:v2
container_name: ntfy
ports:
- "127.0.0.1:3080:80"
command:
- serve
environment:
- TZ=${TZ}
- NTFY_BASE_URL=https://ntfy.example.com
- NTFY_BEHIND_PROXY=true
- NTFY_KEEPALIVE_INTERVAL=45s
- NTFY_AUTH_FILE=/var/lib/ntfy/user.db
- NTFY_AUTH_DEFAULT_ACCESS=deny-all
volumes:
- /docker/ntfy/var/cache/ntfy:/var/cache/ntfy
- /docker/ntfy/etc/ntfy:/etc/ntfy
- /docker/ntfy/var/lib/ntfy:/var/lib/ntfy
healthcheck:
test: ["CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\"healthy\"\\s*:\\s*true' || exit 1"]
interval: 60s
timeout: 10s
retries: 3
start_period: 40s
restart: unless-stopped
init: true
Create the .env file that ${TZ} reads from before the first start:
echo 'TZ=America/Denver' > /docker/.env
Put your own time zone in that file. Skip it and Compose starts the container with an empty TZ. Every ntfy timestamp then lands in UTC, and you get to do timezone math on every alert you read.
Bring it up with docker compose up -d, then confirm the binds with docker port uptime-kuma and ss -tlnp. Both should show loopback only.
3001:3001 and you’ve put a full admin UI with a login form on the public internet, firewall or not. 127.0.0.1:3001:3001 is what keeps it private.Three things in that file deserve an explanation.
The image tag is :2, not :latest. Docker Hub’s latest tag for Uptime Kuma still tracks the old 1.x line. The floating major tag follows every 2.x release, which is what you want from routine docker compose pull updates. A future bump to :3 should be a deliberate edit, because a new major version means re-auditing the Caddy allowlist later in this post against the new release’s status page router.
ntfy runs on :v2 for the same reason. Same floating-major logic, same deliberate upgrade path.
Persist /var/lib/ntfy. That volume holds user.db, which is every ntfy user, token, and ACL you’ll create in the next section. Without it, every container recreation wipes your auth and every producer you own needs reconfiguring. With it, moving ntfy between hosts is a directory copy. My migration off the lab VM onto the VPS took zero client reconfiguration, because user.db came along for the ride and my phone and scripts never noticed the server had moved.
DNS First, Then Caddy, In That Order
Create the A records for status and ntfy pointing at the VPS before your first Caddy deploy:
dig +short status.example.com @1.1.1.1
dig +short ntfy.example.com @1.1.1.1
Both must return the VPS address. Caddy issues certificates over the HTTP-01 challenge and can’t get a certificate for a name that doesn’t resolve. Getting this order backwards is the most common first-run failure, and the error message points at TLS rather than at DNS.
The ntfy vhost is an ordinary reverse proxy with one directive that isn’t obvious:
ntfy.example.com {
encode gzip
header {
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
-Server
}
reverse_proxy 127.0.0.1:3080 {
flush_interval -1
}
}
flush_interval -1 disables response buffering. A buffering proxy sits on your messages until the buffer fills, then delivers them in a clump, minutes after the event they were warning you about.
That block and the status page block further down both live in /etc/caddy/Caddyfile. Apply them with sudo systemctl reload caddy. If you want to check the syntax first, run the check as the caddy user rather than as root: sudo -u caddy caddy validate --config /etc/caddy/Caddyfile. The reason why is a 30-minute story in the troubleshooting section.
Locking Down ntfy: deny-all Plus Write-Only Tokens
Out of the box, ntfy is anonymous read-write. On an internet-facing instance that means anyone who guesses or scrapes a topic name can read your alerts. Mine include WAN IP change notifications, so an open instance would leak my WAN IP history to whoever found the topic.
NTFY_AUTH_DEFAULT_ACCESS=deny-all in the compose environment above shuts that off. But now nothing can publish either, so you need two accounts:
docker exec -it ntfy ntfy user add --role=admin owner
docker exec -it ntfy ntfy user add publisher
owneris an admin account that bypasses ACLs. This is what the phone app and the web app log in as.publisheris a plain user granted write-only access on each topic. Every automation uses this account.
Grant the per-topic access, then mint one labeled token per producer:
docker exec -it ntfy ntfy access publisher KumaUptime write-only
docker exec -it ntfy ntfy access publisher Backups write-only
docker exec -it ntfy ntfy token add --label kuma-vps publisher
One token per producer, always. When a script gets compromised you revoke that one token and nothing else in your setup notices. Write-only is the other half: a stolen producer token can publish noise, but it can’t read the alert history it was never meant to see.
My topic layout, one topic per concern so the phone can mute categories independently:
| Topic | What publishes to it |
|---|---|
KumaUptime | Uptime Kuma monitor state changes |
Backups | Backup job failures and stale-archive warnings |
Storage | SMART health and pool free space from the storage box |
Network | WAN IP changes |
Security | fail2ban bans on the VPS and on Jellyfin |
Authorization header must read Bearer <token>, with the prefix. Two of my webhook producers carried the bare token and silently returned 403 for days. Because those alerts are failure-only, a completely dead notification path looked exactly like a healthy quiet one. A silent alert channel is worse than no alert channel, because you trust it. After creating any token, publish a real test message and confirm it arrives.One more expectation to set. ntfy’s message cache lives in memory unless you set NTFY_CACHE_FILE. Connected subscribers get everything live, but history disappears the moment the container restarts. I decided I didn’t need durable history and moved on. If you do want it, set the cache file now rather than finding out the hard way after a restart.
First Run of Uptime Kuma Over an SSH Tunnel
The admin UI sits on 127.0.0.1:3001 and never gets published. To reach it, forward a local port to the VPS loopback:
ssh -N -L 13001:127.0.0.1:3001 <user>@203.0.113.10
Leave that running and open http://127.0.0.1:13001 in your browser to complete the first-run admin account setup.
The flags: -N opens the tunnel without a remote shell, and -L 13001:127.0.0.1:3001 maps local port 13001 to the VPS’s own loopback port 3001. Pick any free local port. Kuma only ever sees a connection originating from 127.0.0.1 on the VPS. Nothing is exposed to the internet. Close the tunnel with Ctrl-C when you’re done.
Put the Kuma admin credentials in your password manager. That account can rewrite every monitor and read every notification target you configure, including the ntfy token.
While you’re in there, open Settings → Reverse Proxy and enable Trust Proxy so Kuma reads X-Forwarded-For and shows real client addresses rather than the proxy’s.

Monitors That Measure What Your Users Experience
Monitor the public hostname, never the LAN IP. Only the public path exercises DNS, the reverse proxy, the tunnel, and the backend together, and that makes it the only check that can catch an outage your users would actually see.
My three monitors, and why each one exists:
- Jellyfin Server (
https://flix.example.com/), the thing people actually complain about. - Jellyseerr (
https://request.example.com/), the request front end, because “I can’t add movies” is the second most common message I get. - Xfinity, a ping monitor aimed at a LAN-side target over the WireGuard tunnel. The name is my ISP’s, and that’s all it is: a WAN reachability check answering “is the internet at the house working” without publishing my home IP on a public page. This is the monitor that needs the tunnel. No tunnel, no third monitor.
Three monitors, not thirty. Every monitor on a public status page is a line item that anyone can read. Restraint here is a security decision as much as a design one.
For interval and retries, pick numbers that catch a real outage without paging you for a two-second blip. A 60 second interval with retries set to 2 or 3 before the notification fires is a reasonable starting point. Tune it after the first week of real data.

Building a Self-Hosted Status Page Worth Checking
In Kuma, go to Status Pages and create one published page. Mine uses the slug media, the title “Jellyfin Media Status”, and one group called Services holding the two monitors non-technical people care about.
Write the display names for humans. “Jellyfin Server”, not “jf-lxc-04 http 8096”.
What to leave off the page: hostnames, IP addresses, port numbers, internal service names, and anything else that reads like an inventory.
The payoff is one link sent to the family. Green means everything is up and running, red means it’s broken and I already know (don’t text me about it). The volume of text messages dropped immediately. A private dashboard only you can see would have done nothing for that.
Write down the slug. You need it for the Caddy config in the next section and again for restores. Rebuild Kuma from scratch with a different slug and the redirect and the allowlist both break.
The Caddy Allowlist That Keeps the Admin UI Off the Internet
This is the most important config in the post.
Uptime Kuma serves its admin dashboard and its public status page from the same port. Proxy the whole vhost and you’ve published the login form, the setup wizard, the Socket.IO endpoint, the upload directory, and the per-monitor badge API. The answer is an allowlist, not a blocklist: name the exact routes the status page needs, proxy only GET and HEAD on those, and 404 everything else at the proxy so it never reaches Kuma at all.
status.example.com {
log {
output file /var/log/caddy/status.access.log {
roll_size 100mb
roll_keep 5
}
format json
}
redir / /status/media
@media_status {
method GET HEAD
path /status/media /status/media/ /status/media/rss /api/status-page/media /api/status-page/heartbeat/media /api/status-page/media/manifest.json /api/status-page/media/incident-history /assets/* /icon.svg
}
handle @media_status {
reverse_proxy 127.0.0.1:3001
}
handle {
respond 404
}
}
Walking the pieces:
redir / /status/mediaexists because the bare root would otherwise fall through to the 404 handler.method GET HEADbecause a status page never needs to POST anything. Every write method dies at the proxy.- The explicit
/api/status-page/*paths are how the page fetches its config, its heartbeats, its manifest, and its incident history. Miss one and the page renders but never updates. - The trailing
handle { respond 404 }catches everything else:/dashboard,/socket.io/,/api/badge/*,/upload/*, any other status page slug, and every non-GET request.
Badges get denied for a specific reason: /api/badge/<id>/status leaks per-monitor state by numeric ID, including monitors that aren’t on the public page.
This allowlist fails closed. If a future Kuma release adds a route the status page needs, a page feature breaks and you notice immediately. A blocklist fails the other way: a new admin route appears and stays public until you happen to look at it. That asymmetry is why I reach for allowlists.
I audited this route list against the Kuma 2.4.0 status page router before deploying it. Re-audit on any major version bump, and write that down somewhere instead of trusting your memory.
One thing makes a path allowlist viable at all: the status page doesn’t need a websocket. Heartbeats arrive over REST. Verify it on your own instance before you trust me. Load the status page with browser devtools open and confirm the heartbeat bars populate with no websocket connection.
Connect Uptime Kuma to ntfy the Right Way
In Kuma, add a notification of type ntfy. The setting to get right is the server URL:
- Server URL:
http://ntfy(the container name on the shared compose network) - Topic:
KumaUptime - Priority: high
- Access token: the
kuma-vpstoken you minted earlier - Set as default, apply to all existing monitors
Use the container name. The public URL is the tempting choice and the wrong one. Container-to-container means alerting still works when Caddy is down, when DNS is broken, when the certificate has expired, and when the entire lab has gone dark. There’s almost nothing left in the alert path that can fail on its own.
Hit Test and confirm the push lands on your phone before you move on. Don’t skip this. That exact assumption is how I ended up with two producers that had been dead for days.
Prove It, Do Not Assume It
Run every line of this from a machine that isn’t the VPS:
curl -sSI https://status.example.com/ | head -n 1 # 302 to /status/media
curl -sS -o /dev/null -w '%{http_code}\n' https://status.example.com/status/media # 200
curl -sS -o /dev/null -w '%{http_code}\n' https://status.example.com/dashboard # 404
curl -sS -o /dev/null -w '%{http_code}\n' https://status.example.com/socket.io/ # 404
curl -sS -o /dev/null -w '%{http_code}\n' https://status.example.com/api/badge/1/status # 404
nc -zv -w5 203.0.113.10 3001 # must NOT connect
curl -sS -o /dev/null -w '%{http_code}\n' -d "test" https://ntfy.example.com/KumaUptime # 403
About that nc line: both “Connection timed out” and “Connection refused” are passes. A timeout means a DROP-style firewall is swallowing the packet. A refusal means REJECT, or nothing listening on the public interface. Either way the port isn’t reachable. What you must never see is succeeded. If it connects, stop and fix that before you do anything else.
Then repeat the last curl with -H "Authorization: Bearer <token>" and confirm it returns 200.

Back Up Both SQLite Files, Because the VPS Is Not in Your Backup Plan
Two small databases now need to be part of your backup plan. kuma.db holds your monitors, the admin account, the status page slug and its uploaded assets, and the ntfy token. user.db holds every ntfy user, token, and ACL.
Stage one runs on the VPS. This script takes a WAL-safe online copy without stopping the container, tars it up with the config and compose file, and keeps 14 dated archives at mode 0640, because both databases contain recoverable secrets:
#!/bin/bash
set -euo pipefail
data_dir="/docker/uptime-kuma/data"
out_dir="/var/backups/kuma-vps"
date_stamp="$(date +%F)"
stage_dir="$(mktemp -d /root/.kuma-stage.XXXXXX)"
trap 'rm -rf "$stage_dir"' EXIT
mkdir -p "$out_dir" "$stage_dir/kuma"
chmod 0750 "$out_dir"
sqlite3 "$data_dir/kuma.db" ".backup '$stage_dir/kuma/kuma.db'"
cp -a "$data_dir/db-config.json" "$stage_dir/kuma/db-config.json"
[ -d "$data_dir/upload" ] && cp -a "$data_dir/upload" "$stage_dir/kuma/upload"
cp -a /docker/compose.yml "$stage_dir/kuma/compose.yml"
archive="$out_dir/kuma-$date_stamp.tar.gz"
tar -czf "$archive" -C "$stage_dir" kuma
chmod 0640 "$archive"
ls -1t "$out_dir"/kuma-*.tar.gz 2>/dev/null | tail -n +15 | xargs -r rm -f
The ntfy version is the same shape with different paths. Here it is in full, because the half of the pair that protects your auth tokens deserves better than “figure it out”:
#!/bin/bash
set -euo pipefail
data_dir="/docker/ntfy/var/lib/ntfy"
conf_dir="/docker/ntfy/etc/ntfy"
out_dir="/var/backups/ntfy-vps"
date_stamp="$(date +%F)"
stage_dir="$(mktemp -d /root/.ntfy-stage.XXXXXX)"
trap 'rm -rf "$stage_dir"' EXIT
mkdir -p "$out_dir" "$stage_dir/ntfy"
chmod 0750 "$out_dir"
sqlite3 "$data_dir/user.db" ".backup '$stage_dir/ntfy/user.db'"
[ -d "$conf_dir" ] && cp -a "$conf_dir" "$stage_dir/ntfy/etc"
cp -a /docker/compose.yml "$stage_dir/ntfy/compose.yml"
archive="$out_dir/ntfy-$date_stamp.tar.gz"
tar -czf "$archive" -C "$stage_dir" ntfy
chmod 0640 "$archive"
ls -1t "$out_dir"/ntfy-*.tar.gz 2>/dev/null | tail -n +15 | xargs -r rm -f
Both scripts call the host’s sqlite3 binary, so apt install sqlite3 on the VPS if it isn’t there. Schedule both in /etc/cron.d, offset by ten minutes so they never collide:
30 3 * * * root /usr/local/sbin/ntfy-backup.sh
40 3 * * * root /usr/local/sbin/kuma-backup.sh
Stage two pulls those archives to a machine at home that is inside your backup system:
15 4 * * * youruser rsync -a -e "ssh -i /home/youruser/.ssh/vps_key" [email protected]:/var/backups/ntfy-vps/ /home/youruser/backups/ntfy-vps/
25 4 * * * youruser rsync -a -e "ssh -i /home/youruser/.ssh/vps_key" [email protected]:/var/backups/kuma-vps/ /home/youruser/backups/kuma-vps/
No --delete on that rsync. A VPS-side deletion should never propagate into the copy you’re keeping.
Once a month, extract the newest pulled archive and check the database inside it:
cd /home/youruser/backups/kuma-vps
tar -xzf "$(ls -1t kuma-*.tar.gz | head -n 1)" -C /tmp
sqlite3 /tmp/kuma/kuma.db 'PRAGMA integrity_check;' # expect: ok
Anything other than ok means that archive is scrap and you fall back to the previous one. If the pulling box doesn’t have the sqlite3 CLI, Python’s bundled module does the same job:
python3 -c "import sqlite3;print(sqlite3.connect('/tmp/kuma/kuma.db').execute('PRAGMA integrity_check').fetchone()[0])"
Run the same check against the newest ntfy-*.tar.gz and /tmp/ntfy/user.db.
The failure signal here is archive age. A backup pipeline that silently stops looks identical to a healthy one right up until the day you need it. I added a check that pages the Backups topic when the newest pulled archive is missing or older than 26 hours.
And yes, that’s circular. If ntfy is down, ntfy can’t tell you its own backup failed. A stale archive is still the ultimate signal, which is why the monthly manual check stays on the calendar.

Restoring Either Database on a Rebuilt VPS
A backup you’ve never restored isn’t a backup, it’s a hope. Rehearse this once while nothing is broken. The middle of an outage is the worst possible time to learn the procedure.
Copy the archive back to the VPS first, then restore Kuma:
docker compose stop uptime-kuma
tar -xzf /root/kuma-2026-07-16.tar.gz -C /tmp
cp /tmp/kuma/kuma.db /docker/uptime-kuma/data/kuma.db
cp /tmp/kuma/db-config.json /docker/uptime-kuma/data/db-config.json
rm -f /docker/uptime-kuma/data/kuma.db-wal /docker/uptime-kuma/data/kuma.db-shm
[ -d /tmp/kuma/upload ] && cp -a /tmp/kuma/upload/. /docker/uptime-kuma/data/upload/
docker compose start uptime-kuma
ntfy is the same dance:
docker compose stop ntfy
tar -xzf /root/ntfy-2026-07-16.tar.gz -C /tmp
cp /tmp/ntfy/user.db /docker/ntfy/var/lib/ntfy/user.db
rm -f /docker/ntfy/var/lib/ntfy/user.db-wal /docker/ntfy/var/lib/ntfy/user.db-shm
cp -a /tmp/ntfy/etc/. /docker/ntfy/etc/ntfy/
docker compose start ntfy
Stop only the affected service, never docker compose down. The compose file is shared, and a down takes both services out at once.
Now verify, because an unverified restore is the same as no restore:
docker exec -it ntfy ntfy user list # owner and publisher should both be there
docker exec -it ntfy ntfy access # per-topic write-only ACLs intact
curl --fail -sS -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer <token>" -d "restore test" https://ntfy.example.com/KumaUptime # 200
For Kuma, open the SSH tunnel from earlier, log in with the restored admin account, and confirm your monitors are listed and the status page slug still reads media. That slug is the one thing a restore can quietly get wrong. Rebuild Kuma from scratch with a different slug and the Caddy allowlist 404s the public page for everyone, while your dashboard looks perfect.
Troubleshooting
Status page loads but never updates.
- A heartbeat API path is missing from the allowlist. Check the Caddy access log for 404s on
/api/status-page/heartbeat/<slug>.
- A heartbeat API path is missing from the allowlist. Check the Caddy access log for 404s on
ntfy subscriptions hang or messages arrive in bursts.
- Missing
flush_interval -1on the reverse proxy. Server-sent events are being buffered.
- Missing
Publishes return 403 and nothing arrives.
- The
Authorizationheader is missing theBearerprefix, or the token’s ACL is scoped to a different topic than the one you’re publishing to.
- The
Certificate issuance fails on the first deploy.
- The DNS record doesn’t resolve yet. HTTP-01 can’t validate a name with no A record.
Port 3001 answers from outside despite a default-drop firewall.
- The port is published on
0.0.0.0. Docker’s forwarding rules bypass theINPUTchain. Fix the bind, not the firewall.
- The port is published on
Message history vanished after a container update.
- In-memory cache, working as configured. Set
NTFY_CACHE_FILEif you want history to survive restarts.
- In-memory cache, working as configured. Set
A restored Kuma starts empty or throws SQLite errors.
- Leftover
kuma.db-walandkuma.db-shmfrom the previous database. Stop the container, delete both, start it again.
- Leftover
Caddy reload hangs after adding a new vhost.
- Mine hung for 30 minutes. Root cause: I ran
caddy validateas root. Validation provisions the config, which created the new vhost’s access log ownedroot:root 0600. Thecaddyservice user couldn’t open that file, the real reload never completed, and the systemd unit sat in a stale reloading state. Fix the ownership on/var/log/caddy/<site>.access.log, then runcaddy reload --force. Validate as thecaddyuser from now on.
- Mine hung for 30 minutes. Root cause: I ran
A major Kuma version bump breaks the status page.
- That is the fail-closed allowlist behaving correctly. Re-audit the route list against the new release and add the paths the page now needs.
Frequently Asked Questions
➤ Do I really need a VPS? Can't I use a second machine at home?
➤ Is a public status page a security risk?
➤ Why ntfy instead of Discord, Telegram, or email?
➤ Can I keep ntfy private instead of exposing it publicly?
deny-all auth and write-only producer tokens gives you off-network delivery without opening topics to anonymous readers. That combination is what makes public exposure defensible here.➤ What happens if my VPS provider has an outage?
Wrapping Up
Your phone gets the alert. Your users get a URL. Neither one depends on the lab being alive.
The security posture, all of it: loopback binds instead of published ports, a Caddy allowlist that fails closed, an SSH tunnel for admin access, write-only per-producer tokens, and nothing on the public page that reads like an inventory.
Monitoring hosted inside the thing it monitors is a nice dashboard. It isn’t a monitoring system.
Next steps once this is running: point fail2ban at ntfy so SSH bans on the VPS push to your Security topic, add your backup job outcomes as another producer, and put the status page link on the household dashboard so people find it before they find your phone number.
Sources
- Uptime Kuma project and documentation: https://github.com/louislam/uptime-kuma
- ntfy documentation (auth, ACLs, tokens, reverse proxy notes): https://docs.ntfy.sh/
- Caddy documentation (matchers,
handle,reverse_proxy, automatic HTTPS): https://caddyserver.com/docs/ - Docker Compose file reference (port publishing and bind addresses): https://docs.docker.com/reference/compose-file/
