undownable

How to monitor Docker containers from outside your host

Updated Aug 19, 2026

Docker is very good at restarting things and very bad at telling you it had to. A container that crash loops all night looks much like one that has been up for a month, and nothing anywhere sends you a message.

The gap between running and working

Container monitoring in most homelabs is a dashboard of green squares, checked when someone remembers. That catches nothing, because the interesting failures do not change the colour of the square.

That last point is the important one. Watchtower, a dashboard, and a self hosted uptime tool all share a fate with the daemon they inspect. Something outside the host has to be keeping time.

Do not expose the Docker API

The obvious idea is to let a monitoring service query Docker directly. Docker documents that binding the daemon to a TCP socket gives unencrypted and unauthenticated direct access by default, and that doing so risks non root users gaining root on the host. Anyone who reaches that socket can give any instruction to the daemon.

There is no version of publishing /var/run/docker.sock or the remote API to the internet that is worth a green dot. Everything below leaves the socket where it is.

Read health locally, report outward

Docker already computes container health when the image or compose file defines a HEALTHCHECK. In the Compose specification the healthcheck block takes test, interval, timeout, retries, and start_period, plus start_interval for faster polling during startup.

services:
  app:
    image: example/app
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 30s

The daemon then tracks a status you can read. Values are starting, healthy, and unhealthy, and State.Health only exists when a healthcheck is defined at all:

docker inspect --format '{{.State.Health.Status}}' app
# healthy

docker inspect --format '{{.State.Running}}' app
# true

Now turn that local truth into an external signal. A cron job pings a heartbeat URL only when the container reports healthy:

*/2 * * * * [ "$(docker inspect -f '{{.State.Health.Status}}' app)" = healthy ] \
  && curl -fsS -m 10 https://undownable.com/ping/your-monitor-id > /dev/null

With no healthcheck defined, fall back to the running state, which still catches the crash loop because a container mid restart is not running when cron looks:

*/2 * * * * [ "$(docker inspect -f '{{.State.Running}}' app)" = true ] \
  && curl -fsS -m 10 https://undownable.com/ping/your-monitor-id > /dev/null

Or probe the service the way a user would, through the published port, which is the most honest test because it exercises the port mapping too:

*/2 * * * * curl -fsS -m 10 http://127.0.0.1:8080/health > /dev/null \
  && curl -fsS -m 10 https://undownable.com/ping/your-monitor-id > /dev/null

One heartbeat per container, or one per stack

This is a question of how specific you want the alert to be.

Approach Monitors used Alert says
One per critical container One each Exactly which service is down
One per compose stack One per stack Something in the media stack is down
One for the host One The whole box is gone

A pragmatic middle ground for a busy host is a single loop that names its containers and only reports in when all of them are up, from one cron line and one monitor:

*/2 * * * * for c in jellyfin sonarr radarr qbittorrent; do \
  [ "$(docker inspect -f '{{.State.Running}}' $c 2>/dev/null)" = true ] || exit 1; \
done; curl -fsS -m 10 https://undownable.com/ping/your-monitor-id > /dev/null

Name the containers rather than enumerating everything running, otherwise a container you deliberately stopped pages you at midnight. Keep a separate ungated heartbeat for the host, so you can tell one dead container from a dead machine.

For containers that are published anyway

If a container serves a public hostname through a reverse proxy or tunnel, monitor it as a website rather than as a container. One keyword check against the real hostname tests the container, the port mapping, the proxy, the certificate, and the DNS record at once.

Use a keyword assertion so a proxy error page cannot pass as success, and add an SSL check on the hostname. Custom request headers are stored encrypted, which covers containers behind Cloudflare Access service tokens or an API key.

Alerting that is not a container

Anything you self host for notifications is a container on a host that can fail. Route alerts from outside instead:

Flap suppression matters more here than anywhere else, because containers legitimately restart. Combine it with a confirmation threshold and a maintenance window over your update schedule, and a nightly image pull stops generating incidents.

Can Undownable check my containers directly?

Not by talking to Docker, deliberately. Docker documents that a TCP bound daemon is unauthenticated by default and that exposing it risks root access to the host. Read health locally with docker inspect and push a heartbeat outward instead.

What if a container has no HEALTHCHECK?

Use docker inspect -f {{.State.Running}} as the gate, or better, curl the published port the way a user would. State.Health only exists when a healthcheck is defined, so a script assuming it gets an empty string and a confusing failure.

How do I catch a container that keeps restarting?

A gated heartbeat catches it naturally: during a restart the container is not running, so the check fails and the ping is skipped. Set the expected interval to about two and a half times your cron period.

Should I have one monitor per container?

For the handful you care about, yes, because the alert then names the service. For the long tail, one monitor gated on a list of container names is enough, plus one ungated heartbeat for the host.

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