undownable

External uptime monitoring for self-hosted services

Updated Aug 19, 2026

Self-hosted services are hard to monitor from outside for one reason: most of them are not reachable from outside. The fix is not to expose them. It is to pick the right direction of travel for each service, and there are only three cases.

Start with one question per service

Can something on the public internet reach this, and if so, does it need credentials? Answer that and the monitoring approach follows mechanically.

Exposure Approach Check types
Public: reverse proxy, port forward, or tunnel Active checks from outside HTTP, keyword, JSON, TCP, ping, DNS, SSL
Gated: Cloudflare Access, API token, basic auth Active checks with custom request headers HTTP, keyword, JSON
Private: LAN or VPN only Heartbeat push from inside Heartbeat

Most homelabs contain all three, and the mistake is trying to force everything into one row. Publishing a service you did not want published so that a monitor can reach it is a bad trade; so is settling for a heartbeat on something you already serve to the world, because the heartbeat cannot tell you your reverse proxy is broken.

Case 1: publicly reachable, use active checks

If the service already answers on a public hostname, check it the way a user would: from outside, over the real path, through the real proxy and the real certificate. This is the only direction that tests the whole chain, DNS through TLS through application.

Prefer a keyword check to a bare status check. An HTTP 200 proves a server answered, not that it answered correctly, and a reverse proxy serving a cached shell or a friendly error page will return 200 all day. Assert on text that only the working application produces:

Type:     Keyword
URL:      https://git.example.com/explore/repos
Keyword:  Explore

Type:     JSON
URL:      https://api.example.com/health
JSON path: services.database
Expected:  ok

For things that are not HTTP, a TCP check proves the port accepts a connection: 25 or 587 for mail, 51820 for a WireGuard endpoint, 5432 for a database you deliberately expose to a peer. Add a DNS check if you run your own authoritative records, and an SSL check on every public hostname. Certificate expiry is the rare outage you can see coming, with escalating warnings at thirty, fourteen, seven, and one day before it bites.

Case 2: not exposed at all, push a heartbeat

A media server, a backup runner, a Home Assistant box, a monitoring stack: things that should never have a public port. Nothing outside can connect to them, so invert the direction. The service, or a small cron job beside it, calls out to a heartbeat URL on a schedule, and the external monitor alerts when the calls stop.

# On the private host. Outbound HTTPS only, no inbound rule, no port forward.
*/5 * * * * /usr/bin/curl -fsS -m 10 --retry 3 https://undownable.com/ping/YOUR-MONITOR-ID > /dev/null

That proves the host is alive with working internet. To prove the service is alive, gate the ping on a local check first, so a dead application stops the heartbeat even though the machine is fine:

*/5 * * * * /usr/bin/curl -fsS -m 10 http://127.0.0.1:8123/ > /dev/null && /usr/bin/curl -fsS -m 10 https://undownable.com/ping/YOUR-MONITOR-ID > /dev/null

Use the absolute path to curl: cron's PATH is minimal, and a bare curl that works in your shell can fail silently from cron. Set the expected interval looser than the send rate so one skipped run does not page you.

Case 3: reachable but gated, send credentials with the check

The middle case is the interesting one, and it is where people usually give up and drop to a heartbeat. Services behind Cloudflare Access, an API token, or basic auth are reachable from the internet; they just refuse anonymous requests. An external check that treats a 403 as an outage is useless, and one that treats it as success is worse.

The answer is custom request headers, which Undownable stores encrypted and sends with every HTTP, keyword, or JSON check. For Cloudflare Access, create a service token and pass its pair:

CF-Access-Client-Id:     0123abcd....access
CF-Access-Client-Secret:  <the service token secret>

One thing to get right on the Cloudflare side: a service token only works if the application has a policy that accepts it, and that policy must be a service auth (non-identity) rule. An Allow policy scoped to your email will keep rejecting the token no matter how correct the headers are.

The same mechanism covers the rest of this category: a bearer token for an API, a shared secret header, basic auth as an Authorization header. Point the check at a real endpoint, not a health stub that returns fine while the database is unreachable.

VPN-only services are the exception: there is no header that makes a WireGuard-only host answer an internet request. Monitor those with a heartbeat, and separately run a TCP check against the VPN endpoint itself so you know the tunnel is accepting connections.

Alerting that still works when your network does not

This is the part that quietly ruins otherwise good setups. If the alert about your LAN outage has to traverse your LAN, you will not get it. Audit the path, not just the destination.

Undownable supports self-hosted ntfy servers with access tokens, which is the right choice for routine alerts about services. For the small set of monitors that watch your infrastructure itself, pick something that cannot be taken down by the thing it is reporting on, and confirm the phone actually buzzes.

A worked example

Five services, six monitors, inside a free plan of ten with every alert channel included. Add the rest of the fleet as you go; the Pro plan raises the ceiling to fifty monitors at one-minute checks for a flat price per team, with no per-monitor or per-seat charges, and both the REST API and an MCP server if you would rather define monitors from a script than a form.

Do I have to expose my services to monitor them externally?

No. Anything you do not want published can push a heartbeat outward instead, which needs only outbound HTTPS. Expose a service because you want it reachable, never because a monitor demands it.

How do I monitor something behind Cloudflare Access?

Create a service token in Cloudflare, add a service auth policy on the application that accepts it, then send the CF-Access-Client-Id and CF-Access-Client-Secret headers with the check. Header values are stored encrypted and sent on every request.

Is a status check enough, or do I need keyword matching?

For a static asset a status check is usually fine. For anything dynamic, use a keyword or JSON check: a 200 from a proxy error page, a cached shell, or a default vhost is the single most common way a monitor reports healthy through a real outage.

What about services on a VPN?

No external check can reach them, so use a heartbeat from inside the tunnel. Pair it with a TCP or ping check against the VPN endpoint so you can tell a broken service apart from a broken tunnel.

How many external monitors does a homelab actually need?

Fewer than people expect. One per publicly reachable hostname, one heartbeat per private machine you would notice going quiet, and an SSL check on each certificate. Most homelabs land between five and fifteen.

Related reading

Monitoring that watches from the outside

Free plan with 10 monitors, plus a 14-day Pro trial. No credit card required.

Start free