Self-hosting is having a renaissance. Between Docker Compose, cheap VPSes, and a thriving ecosystem of open-source apps — Nextcloud, Gitea, Immich, Home Assistant, Vaultwarden, n8n — it has never been easier to run your own stack. It has also never been easier to find out three days late that your backup job died, your reverse proxy is returning 502s, or your Let's Encrypt certificate quietly failed to renew.

The uncomfortable truth about self-hosted monitoring is that the machine running your services cannot be trusted to tell you when it is in trouble. This guide covers how to monitor Docker containers and self-hosted apps from the outside: what local healthchecks do and do not give you, which layers fail independently, how to watch services that are not publicly reachable, and a concrete CronAlert setup you can copy. CronAlert is agentless, so nothing here requires installing anything on your host — just HTTP checks, content assertions, and heartbeats.

Why Docker's HEALTHCHECK is not monitoring

Docker's HEALTHCHECK instruction runs a command inside the container on an interval — typically a curl against the app's local port — and flips the container's status between healthy and unhealthy. It is genuinely useful: Compose can gate dependent services on it, Swarm and Kubernetes use equivalent probes to restart bad containers, and docker ps shows you the state at a glance.

But notice what it does not do. Plain Docker does not restart an unhealthy container on its own, and nothing notifies you — the flag just sits there until something reads it. More fundamentally, the healthcheck lives inside the failure domain it is supposed to watch. If the Docker daemon hangs, the host kernel panics, the disk fills, the VPS provider has an outage, or your DNS breaks, the healthcheck is gone along with everything else — and gone silently. A local check can never distinguish "everything is fine" from "everything is so broken that nothing can report."

The rule that follows: local healthchecks are for orchestration; external checks are for alerting. Keep your HEALTHCHECK instructions — they make restarts and dependency ordering work — and add an outside observer that checks your services from the public internet, where your users are.

The same-box monitoring trap

The most common self-hosted answer to this is "I run Uptime Kuma in a container." Kuma is a lovely tool — we compare it honestly in CronAlert vs Uptime Kuma — but running your monitor on the same host as the services it watches recreates the exact blind spot you were trying to close. When that host dies, the monitor dies with it, and the alert that should wake you up never fires. Silence from a dead monitor looks identical to a healthy fleet.

You have three ways out: run the monitor on a second machine in a different location and monitor each box from the other, use a hosted service as the external vantage point, or both. The hybrid is popular for a reason — keep Kuma for LAN-only detail if you like it, and let an external service watch everything public plus the monitor host itself. The external layer is the one that catches power loss, provider outages, DNS breakage, and full-host failures.

Monitor each layer that fails independently

A self-hosted stack is a chain: DNS → TLS → reverse proxy → container → the app inside the container → the jobs that app depends on. Each link fails independently, and each failure looks different from outside:

  • Each service's public URL. One HTTP monitor per app, through your reverse proxy, exactly the way a user reaches it. A 502 or 503 from Traefik, Caddy, or nginx means the proxy is alive but the upstream container is down or unready — the single most common self-hosted failure signature. See HTTP status codes for reading these signals precisely.
  • A keyword per app. A container can be "running" while the app inside is wedged — a database file locked, a migration half-applied, a config error rendering a blank page. A keyword assertion on a string that only appears when the app truly rendered (a login label, a dashboard element) catches "up but wrong" states a status code misses.
  • SSL certificates on every domain. Let's Encrypt auto-renewal via Caddy, Traefik, or certbot fails more often than people expect — ACME challenges blocked by a firewall change, moved DNS, rate limits. External SSL monitoring warns you days before expiry regardless of why renewal broke.
  • DNS, if you use dynamic DNS. Homelab setups on residential connections live and die by the DDNS updater. If your IP changes and the update fails, every service goes dark at once; see DNS monitoring.
  • The admin panels you depend on. Portainer, the Proxmox UI, your router's interface — if you would need it during an incident, it deserves a check. Our guide to monitoring internal tools covers the pattern.

Heartbeats: backups, cron jobs, and NAT-hidden services

The failures that hurt self-hosters most are not websites going down — they are scheduled jobs going silent. A restic or borg backup that has not actually run in six weeks. A watchtower update cycle that stopped. A database dump cron entry that broke when you renamed a container. Nothing errors visibly; the work just stops, and you discover it the day you need the backup.

Heartbeat monitoring inverts the check: instead of CronAlert reaching in, your job reaches out. Append a ping to the end of the job — restic backup ... && curl -fsS https://cronalert.com/api/heartbeat/TOKEN — so the request fires only on success. Tell CronAlert the expected interval plus a grace period, and if the ping stops arriving, you get an alert that the job went silent. The && matters: a failing backup should not ping, so failure and silence look the same to the monitor — which is exactly what you want. Our cron job heartbeat guide covers grace periods, runtime limits, and edge cases.

The same pattern solves the NAT problem. An external monitor cannot reach your LAN-only services — Home Assistant on a private VLAN, an internal wiki, anything you deliberately keep off the internet. But those services can reach out. A small cron job on the host curls each internal service locally (or checks docker inspect --format='{{.State.Health.Status}}') and pings a heartbeat only when everything passes. You get external alerting for internal services without exposing a single port.

Watch the whole host with one heartbeat

One cheap trick gives you host-level coverage: a cron entry that pings a heartbeat every few minutes, unconditionally. If the host loses power, the disk fills until cron cannot run, or the network drops, the ping stops and CronAlert alerts you — even though nothing on the box was capable of sending an alert. Combined with external HTTP checks on your public services, this closes the loop: the HTTP checks tell you what users see, and the host heartbeat tells you the machine itself is alive. For deeper background-job patterns, see background worker monitoring.

A concrete CronAlert setup for a Compose stack

Here is an end-to-end setup for a typical VPS or homelab running Docker Compose behind Traefik or Caddy, all within CronAlert's free plan (25 monitors, 3-minute interval) after you create a free account:

  • One HTTP monitor per public service. Point each at the service's public URL, expect a 200, and add a keyword assertion for a string only that app renders.
  • SSL monitoring on every domain. Enable certificate checks so a failed Let's Encrypt renewal warns you days ahead.
  • A host-liveness heartbeat. Add */5 * * * * curl -fsS https://cronalert.com/api/heartbeat/TOKEN to the host's crontab.
  • A heartbeat per scheduled job. Backups, database dumps, updates — append && curl -fsS <heartbeat-url> to each so only success pings.
  • A LAN sweep heartbeat for internal services. A small script curls each internal service locally and pings its heartbeat only when all pass.
  • Wire up alert channels. Email, Slack, Discord, Telegram, webhooks, or PWA push on the free plan; Teams, PagerDuty, Opsgenie, and Splunk On-Call on Pro and above.

Every plan includes the full REST API and an MCP server, so you can script monitor creation for your whole Compose stack — or have Claude Code or Cursor do it — instead of clicking through a UI once per service. If you later want checks from multiple vantage points worldwide, the Team plan adds multi-region quorum checks so one flaky route never pages you.

Frequently asked questions

Isn't Docker's HEALTHCHECK enough to monitor my containers?

No. HEALTHCHECK runs inside the container and only flips a status flag — plain Docker neither restarts unhealthy containers nor notifies anyone. And because it is local, a hung daemon, dead host, or broken network takes the healthcheck down with everything else, silently. Keep it for orchestration; add external checks for alerting.

Can I run my uptime monitor on the same server as my apps?

You can, but when the server dies the monitor dies with it, and you get silence instead of an alert. The monitoring vantage point must sit outside your failure domain — a hosted service checking from the public internet, or at least a second machine elsewhere, with each box watching the other.

How do I monitor self-hosted services that aren't exposed to the internet?

Use heartbeats. A cron job on the host checks each internal service locally and pings a unique CronAlert heartbeat URL only when the checks pass. If the ping stops, you get an alert — external alerting for LAN-only services without exposing any ports.

How should I monitor Let's Encrypt certificate renewals on a self-hosted stack?

Two layers: external SSL monitoring on every public domain so you are warned days before expiry no matter why renewal failed, plus a heartbeat on the renewal job itself if you run certbot from cron, so you hear about failures weeks ahead instead of days.

What should I monitor besides the container itself?

Every layer that fails independently: each service's public URL through the reverse proxy (502/503 means the upstream is down), a keyword per app for "up but wrong" states, SSL certificates, DNS if you use dynamic DNS, admin panels you would need during an incident, and heartbeats on scheduled jobs — especially backups.

Give your stack an outside observer

Self-hosted stacks fail at layers a local healthcheck cannot see: dead hosts, failed cert renewals, silent backup jobs, and proxies serving 502s while every container claims to be healthy. External HTTP checks with keyword assertions, SSL monitoring, and a handful of heartbeats close those gaps — and all of it fits in CronAlert's free plan. Create a free CronAlert account, add a monitor per service, and put a heartbeat on the backup job you have been meaning to check on.

Related reading: CronAlert vs Uptime Kuma, cron job heartbeat monitoring, SSL certificate monitoring, monitoring internal tools, and DNS monitoring.