Skip to content

← All heartbeat examples · What Heartbeat is

Heartbeat from a Docker container

A ping that leaves a container proves three things at once: the host is up, the Docker daemon started the container, and the container can reach the internet. Where you put the ping decides what else it proves. Three forms, from the simplest to the most telling. Every one of them needs only the slot's ping URL from Settings → Heartbeats, kept in a variable called HEARTBEAT_URL.

Keep the URL out of the image. Never bake it in with a Dockerfile ENV line: an image pushed to a registry carries it to everyone who can pull. Pass it at run time from a .env file or an --env-file, and list that file in .gitignore and .dockerignore.

1. "The stack is up": a Compose sidecar

A throwaway container that pings once a minute for as long as the Compose project runs. If the host, the daemon or the project goes down, so does the ping. Compose reads .env next to the file on its own, and the doubled $$ stops Compose from expanding the variable before the shell inside the container sees it.

# .env  (chmod 600, never committed)
HEARTBEAT_URL=https://freshjots.com/hb/<token>
# compose.yaml
services:
  app:
    image: ghcr.io/you/app:latest

  heartbeat:
    image: curlimages/curl:latest
    restart: unless-stopped
    environment:
      HEARTBEAT_URL: ${HEARTBEAT_URL}
    command: >
      sh -c 'while true; do curl -fsS --max-time 10 "$$HEARTBEAT_URL" >/dev/null; sleep 60; done'

What it does not prove: the app container can be crash-looping while the sidecar pings happily. When you need the ping to mean "the app is healthy", move it into the healthcheck.

2. "The app is healthy": ping from the HEALTHCHECK

Docker already runs a health command inside the container on an interval. Put the ping after the local check, so it fires only when the app answered, and end it with || true so a network blip on the ping can never mark the container unhealthy. The healthcheck's own interval does the scheduling: 30 seconds is twice a minute, well inside the limits. The command runs with the container's environment, so HEARTBEAT_URL only has to be passed at run time.

# Dockerfile  (localhost:3000/up is your app's own health URL and port)
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
  CMD curl -fsS http://localhost:3000/up || exit 1; \
      curl -fsS --max-time 5 "$HEARTBEAT_URL" >/dev/null 2>&1 || true

The same thing without rebuilding the image, from Compose:

services:
  app:
    image: ghcr.io/you/app:latest
    env_file: .env
    healthcheck:
      test:
        - CMD-SHELL
        - curl -fsS http://localhost:3000/up || exit 1; curl -fsS --max-time 5 "$$HEARTBEAT_URL" >/dev/null 2>&1 || true
      interval: 30s
      timeout: 10s
      start_period: 20s

This closes a gap Docker leaves open: a plain docker run never restarts an unhealthy container, it only marks it, and nobody is watching the mark. The missing ping turns the mark into an email. Images without curl (Alpine, BusyBox) have wget -qO- -T 5 "$HEARTBEAT_URL" instead, and a distroless image can ping from inside the application.

3. "The host is alive": one docker run

No Compose file, no cron: a single detached container with a restart policy pings for as long as the daemon runs, and comes back after a reboot on its own. The URL comes from a root-only env file so it never lands in shell history or ps output.

docker run -d --name freshjots-heartbeat --restart unless-stopped \
  --env-file /etc/heartbeat.env \
  curlimages/curl:latest \
  sh -c 'while true; do curl -fsS --max-time 10 "$HEARTBEAT_URL" >/dev/null; sleep 60; done'

A reboot that takes under two minutes writes nothing into the record; a longer one is an OFF / ON pair with the minutes between them.

4. A worker container

The three forms above prove the container. A worker that is connected but not consuming (a stuck job, a dropped queue connection, a loop that exited while the process lingered) looks fine to all three. For a worker the ping belongs inside the loop, after each unit of work: see background workers and job queues. Kubernetes has its own form, a one-minute CronJob that proves the scheduler and egress, on the main examples page.

5. Gotchas

  • All containers on a host share its IP. The limits are 120 hits a minute per token and 60 a minute per IP. Once a minute per container is nothing; ten containers each pinging every second is a 429, and curl -f will tell you so in the container's logs.
  • One slot per container. Two containers on one slot hide each other's death. Give each its own URL and put the service name in the slot's label.
  • docker compose config prints the resolved URL. Useful to check the variable arrived, dangerous in a pasted log or a screenshot. If it leaks, Rotate URL on the slot page kills the old one at once.
  • A 404 from the ping means the slot is switched off, the URL was rotated, or the token was mangled on the way in (a stray quote in the env file is the usual cause). Nothing is recorded on the Fresh Jots side.
  • Staging gets its own slot. A staging stack pinging the production slot masks a dead production stack.