Healthchecks v5: Self-Hosted Cron Job Monitoring That Tells You When Backups Stop

Healthchecks is a BSD-licensed, self-hosted cron job monitor that alerts you when backups and scheduled tasks stop checking in. What is new in v5 and how to run it with Docker Compose.

Share
Healthchecks dashboard listing cron job checks with status, period and last ping
Screenshot: Healthchecks project (BSD-3-Clause)

Most homelab monitoring answers one question: is this service up? Uptime checks are great for that, but they miss a whole category of failures. Your nightly backup script, the certificate renewal, the database dump, the rsync to your NAS: none of them are "services". When they silently stop running, nothing is down, and you usually find out at the worst possible moment.

Healthchecks solves exactly this problem. Instead of polling your systems, it waits for your jobs to check in. If a ping doesn't arrive on time, you get an alert. The project just shipped v5.0 (October 20, 2026) and v5.0.1 two days later, with a new web server in the Docker image, so it's a good moment to take a look at it and set it up from scratch.

What is Healthchecks?

Healthchecks is an open-source cron job and background task monitoring service written in Python and Django. It lives on GitHub at healthchecks/healthchecks, has a little over 10,000 stars and is licensed under the BSD 3-Clause license. The same code powers the hosted service at healthchecks.io, but you can run your own instance with the official Docker image.

The idea is simple:

  1. You create a check in the web UI and get a unique ping URL.
  2. Your job calls that URL (for example with curl) every time it finishes.
  3. You tell Healthchecks how often to expect a ping (Period) and how long to wait before raising the alarm (Grace Time), or you give it a cron expression.
  4. When a ping is late, Healthchecks marks the check as down and notifies you.

This is often called a "dead man's switch": silence is the signal.

The Healthchecks My Checks dashboard with several cron job checks and their status
The "My Checks" dashboard shows all checks with their period, grace time and last ping in a live-updating list. Screenshot: Healthchecks project (BSD-3-Clause). Source

Key features

According to the README and docs, Healthchecks comes with:

  • A live-updating dashboard with tags, filters and a per-check event log.
  • Simple period/grace schedules or cron expressions (parsed with the project's own cronsim library), including time zones.
  • Several signal types: success, /start, /fail, /log and reporting a script's exit code directly, e.g. /$?.
  • Run time measurement: send a /start ping and Healthchecks shows how long each run took. If a job starts but doesn't finish within the grace time, it's treated as failed.
  • Pinging by email via an optional built-in SMTP listener.
  • 25+ integrations for notifications, including email, webhooks, Slack, Discord, Telegram, Matrix, ntfy, Pushover, Signal, PagerDuty and more, plus optional Apprise support.
  • A Management API, status badges, monthly email reports, Prometheus metrics, projects and team members with read-only access, and WebAuthn 2FA.
  • Database choice: SQLite (default), PostgreSQL, MySQL or MariaDB.
Healthchecks check details page showing the ping URL and a log of received pings
The check details page with the ping URL, usage options and the event log. Screenshot: Healthchecks project (BSD-3-Clause). Source

What's new in v5.0

The headline change in v5.0 is under the hood: the Docker image now uses gunicorn instead of uWSGI as its web server. That's why it's a major version, even though the app itself feels the same. A few things to know if you're upgrading, all from the release notes:

  • If you use a customized docker-compose.yml, the command must now run /opt/healthchecks/run.sh instead of uwsgi.
  • The number of web workers is set with GUNICORN_WORKERS. If it's not set, the image falls back to the old UWSGI_PROCESSES, and otherwise defaults to 4.
  • If any background process in the container (sendalerts, sendreports, smtpd) crashes, the whole container now exits instead of restarting just that process. With a Docker restart policy, that means everything restarts cleanly together.
  • The Shell integration now passes data via environment variables and can be edited after creation, and email notifications handle SMTP connection errors more gracefully.

v5.0.1 added GUNICORN_PORT to change the listening port and GUNICORN_DISABLE_LOGGING to turn off the access log, and moved the image to a Python 3.14 base.

Why self-host it?

The hosted healthchecks.io service is perfectly fine for many people. Self-hosting makes sense when:

  • you want to monitor jobs on machines that should never talk to a third-party service,
  • you want unlimited checks and team members without plan limits,
  • you already run a reverse proxy and a database, so one more small container is cheap,
  • you like the idea of your monitoring data staying on your own hardware.

One honest caveat up front: a self-hosted monitor is only as reliable as the box it runs on. More on that below.

Installing Healthchecks with Docker Compose

The project ships a sample Compose file in the docker/ folder of the repository. The version below is based on it, but uses the pre-built image, pins the version, adds health-based startup ordering and keeps the database off the host network. It assumes you already run a reverse proxy that terminates TLS (Traefik labels are included as an example).

1. Create a folder and an .env file

mkdir -p /opt/healthchecks && cd /opt/healthchecks

Generate two random values, one for the database password and one for Django's secret key:

openssl rand -hex 24   # use for DB_PASSWORD
openssl rand -hex 50   # use for SECRET_KEY

Then create .env:

# Database
DB=postgres
DB_HOST=db
DB_PORT=5432
DB_NAME=hc
DB_USER=postgres
DB_PASSWORD=change-me

# Core
SECRET_KEY=change-me-too
SITE_ROOT=https://hc.example.com
SITE_NAME=My Healthchecks
DEBUG=False

# Private instance: no public sign-ups
REGISTRATION_OPEN=False

# WebAuthn 2FA (domain without scheme or port)
RP_ID=hc.example.com

# Outgoing email (needed for login links, alerts and reports)
DEFAULT_FROM_EMAIL=healthchecks@example.com
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_HOST_USER=healthchecks@example.com
EMAIL_HOST_PASSWORD=change-me-smtp
EMAIL_USE_TLS=True

# gunicorn (v5+)
GUNICORN_WORKERS=2

A few notes on these values, all from the official configuration docs:

  • ALLOWED_HOSTS is filled in automatically from the domain in SITE_ROOT, so you only need it if you serve the instance on several domains.
  • PING_ENDPOINT defaults to SITE_ROOT + /ping/, which is what you want behind a reverse proxy.
  • REGISTRATION_OPEN=False is recommended for private instances that still need to be reachable from the internet so remote machines can ping them. You can still invite people into your projects.
  • GUNICORN_WORKERS=2 saves memory on small servers. The default is 4.

Lock the file down with chmod 600 .env.

2. Create docker-compose.yml

services:
  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      - POSTGRES_DB=${DB_NAME}
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d ${DB_NAME}"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - internal

  web:
    image: healthchecks/healthchecks:v5.0.1
    restart: unless-stopped
    env_file:
      - .env
    depends_on:
      db:
        condition: service_healthy
    networks:
      - internal
      - proxy
    labels:
      - traefik.enable=true
      - traefik.docker.network=proxy
      - traefik.http.routers.healthchecks.rule=Host(`hc.example.com`)
      - traefik.http.routers.healthchecks.entrypoints=websecure
      - traefik.http.routers.healthchecks.tls.certresolver=letsencrypt
      - traefik.http.services.healthchecks.loadbalancer.server.port=8000

volumes:
  db-data:

networks:
  internal:
  proxy:
    external: true

Adjust the network name, entrypoint and certificate resolver to match your own Traefik setup. If you don't use a reverse proxy and just want to try it locally, drop the labels and the proxy network and add ports: ["8000:8000"] to the web service instead.

The image runs as a non-root hc user, runs database migrations automatically on startup and has a built-in Docker health check, so docker compose ps will show whether it's healthy.

3. Start it and create an admin account

docker compose up -d
docker compose logs -f web

Once the migrations are done, create a superuser:

docker compose run --rm web /opt/healthchecks/manage.py createsuperuser

Open https://hc.example.com, log in and create your first check.

Reverse proxy: two headers matter

The Docker README is very clear on this, and it's easy to get wrong:

  • Healthchecks uses X-Forwarded-Proto to decide whether a request is HTTPS. If your proxy doesn't set it, you'll see "403 CSRF verification failed" errors when logging in.
  • It trusts X-Forwarded-For for the client IP, which is used for things like rate limiting on the login form. Your proxy must set it and must not pass through a value supplied by the client.

Traefik sets both headers by default and, unless you explicitly configure it to trust forwarded headers from certain IPs, it doesn't keep client-supplied values. With NGINX you need proxy_set_header X-Forwarded-Proto $scheme; (and the usual X-Forwarded-For line) yourself.

Pinging your first job

Each check gets a URL like https://hc.example.com/ping/<uuid>. The simplest integration is one curl at the end of a cron job:

# crontab: run the backup at 03:00 and report success
0 3 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 --retry 5 -o /dev/null https://hc.example.com/ping/your-uuid-here

Better: report the exit code, so failures are flagged immediately instead of after the grace time:

#!/bin/sh
/usr/local/bin/backup.sh
curl -fsS -m 10 --retry 5 -o /dev/null https://hc.example.com/ping/your-uuid-here/$?

And if you want run times, send a /start ping first:

#!/bin/sh
URL=https://hc.example.com/ping/your-uuid-here
curl -fsS -m 10 --retry 5 -o /dev/null "$URL/start"
/usr/local/bin/backup.sh
curl -fsS -m 10 --retry 5 -o /dev/null "$URL/$?"

Exit code 0 counts as success, anything from 1 to 255 as a failure. If you send a POST request, Healthchecks stores the request body (up to PING_BODY_LIMIT, 10,000 bytes in the sample config), which is handy for attaching the last lines of a log.

Instead of UUIDs, you can also use slugs with a project ping key (/ping/<ping-key>/<slug>). With ?create=1, a check is even created automatically on its first ping, which is nice for provisioning many machines with Ansible.

Healthchecks dialog for configuring a check schedule with a cron expression
Instead of period and grace time, a check can use a cron expression with a time zone. Screenshot: Healthchecks project (BSD-3-Clause). Source

Configuration tips

  • Set up email first. Login links, email alerts and monthly reports all depend on working SMTP. Use the _FILE variants (for example DB_PASSWORD_FILE or EMAIL_HOST_PASSWORD_FILE) if you prefer Docker secrets over plain env vars.
  • Enable 2FA. Set RP_ID to your domain and register a security key or passkey under your account settings.
  • Leave the Shell integration off unless you fully trust every user. It runs local commands with the permissions of the alerting process, which is why SHELL_ENABLED defaults to False.
  • Private IPs for webhooks are blocked by default (INTEGRATIONS_ALLOW_PRIVATE_IPS=False). If you want to send webhooks to services in your LAN, you have to opt in, and you should understand why that default exists.
  • ntfy, Gotify or Matrix make great notification targets in a homelab. Apprise is available too (APPRISE_ENABLED=True, the package is already included in the official image).
  • Show status on your dashboard. If you use Homepage, it has a built-in Healthchecks widget that shows total up/down counts or a single check, using a read-only API key from your project settings.
  • Back up the database. A regular pg_dump of the db container is enough, and of course you can monitor that backup job with Healthchecks itself.

Limitations and honest notes

  • Who watches the watcher? If the machine running Healthchecks goes down, you won't get alerts about anything. Many people pair a self-hosted instance with an external uptime check on the Healthchecks URL itself, or run it on a different machine than the jobs it monitors.
  • It's not an uptime monitor. Healthchecks doesn't actively probe websites or ports. For that you still want something like Uptime Kuma or your existing monitoring.
  • Email is practically mandatory. You can run it without SMTP, but logins, invites and email alerts won't work properly.
  • The v5 upgrade can break custom setups. If your Compose file still calls uwsgi, update it to /opt/healthchecks/run.sh before pulling v5.
  • The sample Compose file is minimal. As the project itself notes, it's a single database and a single web node on one host, without TLS. That's fine for a homelab, but it's not a high-availability setup.

Healthchecks vs. Uptime Kuma

Both are popular in homelabs, and they complement each other rather than compete. Uptime Kuma (MIT) is primarily an active uptime monitor: it checks HTTP(s), TCP, DNS, ping, Docker containers and more. It also has a Push monitor type where a job calls a URL, similar to Healthchecks' basic ping.

Healthchecks focuses entirely on that "push" side and goes much deeper there: cron expressions with time zones, start/fail/exit-code signals, run time tracking, ping bodies, email pings, slugs with auto-provisioning and a full management API. If cron jobs and backups are what you worry about, Healthchecks is the more specialized tool. If you mainly want to know whether your websites are reachable, start with Uptime Kuma.

Conclusion

Healthchecks is one of those tools that seems almost too simple at first: a URL, a timer and an alert. But that simplicity is exactly why it works. It catches the failures nobody notices, like a backup that stopped three weeks ago or a certificate renewal that quietly broke. The v5 release modernizes the Docker image without changing what makes the app good, and with a small Postgres container and a reverse proxy, it's up and running in a few minutes.

If you only add one thing to your homelab this weekend, make it a ping at the end of your backup script.

Looking for more self-hosted tools? Check out our posts on Termix, the self-hosted SSH and server management app, and the open source trends of October 2026.

Sources