Skip to content

How to Deploy Healthchecks on a VPS

Updated Aug 2026

verified on Ubuntu 26.04 · Aug 2026

Self-host Healthchecks on a VPS — the dead-man's-switch monitor that tells you when a cron job stopped running, with Docker Compose, Postgres, HTTPS and working alerts.

Before you start
  • A small VPS — 1 vCPU / 1 GB RAM is comfortable
  • A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
  • A domain you can point at the server, on a box separate from the jobs you watch
  • Docker Engine + Compose installed (see the base guide below)
  • An SMTP account or a chat webhook — alerts are the entire point

What Healthchecks is — and what it is not

Healthchecks is a cron job and background task monitor. Every check you create gets a unique URL; your backup script, cron entry, or worker calls that URL when it finishes successfully. If the call does not arrive on time, Healthchecks alerts you. That is the whole model, and it is called a dead man's switch: silence is the failure signal.

Read this part before you install anything, because it is the thing people get wrong: Healthchecks never requests your website. It is a purely passive monitor. It does not poll an endpoint, it does not check a TLS certificate, it does not ping a host, and it will not notice your web server returning 500s to every visitor. If you arrived here from an uptime search, this is not the tool you were looking for — or rather, it is only half of it.

The two halves:

  • Outbound / active monitoring asks "is the service answering?" That is Uptime Kuma or Gatus, poking your endpoints from outside.
  • Inbound / passive monitoring asks "did the job that nobody watches actually run?" That is Healthchecks, and nothing an endpoint checker does covers it.

The second half is the one people skip, and it is where the ugly failures hide. A nightly backup that silently stopped three weeks ago produces no alert from any uptime monitor on earth, because the website is fine. Healthchecks is how you find out on day one instead of on the day you needed the backup. Most serious setups run both, and they cost almost nothing together.

It is a Django application with Postgres behind it, under the BSD-3-Clause licence — the self-hostable counterpart to a hosted service like UptimeRobot's heartbeat feature, without the per-check pricing.

Server sizing

Healthchecks is light. It receives small HTTP requests, writes rows, and sends notifications; there is no polling loop and no metric ingestion. The database is the only thing that grows, and it grows slowly — a ping log, not a time-series store.

  • 1 GB RAM / 1 vCPU — comfortable for personal or small-team use with Postgres on the same box. This is what the catalogue lists and what this guide assumes.
  • 2 GB RAM — worth it if you are running hundreds of checks with a long ping-log retention, or sharing the box with other services.

10–20 GB of disk is plenty. The one thing worth planning is the same placement rule that applies to every monitor: put it somewhere other than the machines whose jobs it watches. If the monitor lives on the server running the cron jobs, a dead server means no pings and no alerts about the missing pings. Prefer a different provider or region from your main workload.

Because the workload is tiny, this is a few dollars a month — a Hetzner box, a small Kamatera instance, or a base DigitalOcean droplet all have headroom to spare.

Prepare the server

This guide assumes Docker Engine and the Compose plugin are installed, along with a non-root deploy user and a ufw firewall. If not, work through Docker & Compose on Ubuntu first.

Open SSH and the reverse proxy ports only. Healthchecks' own port and Postgres both stay internal:

sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw enable
sudo ufw status verbose

Inbound is what matters here, and it is worth stating plainly: this server has to be reachable from wherever your jobs run. A monitor behind a VPN that your production cron boxes cannot reach will report every job as down. If your jobs run on machines with no outbound internet access, Healthchecks is not the right shape for them.

Install Healthchecks

Upstream ships a reference Compose file inside the repository, so the install starts with a clone rather than a single compose file you write yourself:

git clone https://github.com/healthchecks/healthchecks.git
cd healthchecks/docker
cp .env.example .env

Open .env and fill in four values before you start anything. All four have defaults or blanks that will bite you:

SECRET_KEY=<a long random string>
DB_PASSWORD=<a long random string>
ALLOWED_HOSTS=hc.example.com
SITE_ROOT=https://hc.example.com

Generate the two secrets rather than inventing them:

python3 -c 'import secrets; print(secrets.token_urlsafe(50))'
openssl rand -base64 36

What each one does, because three of the four cause confusing failures when they are wrong:

  • SECRET_KEY signs sessions and tokens. Never share it, never commit it, and do not reuse the example value — a known key means forgeable sessions.
  • DB_PASSWORD is used by both the app and the Postgres container in this compose file, so set it once here and let both read it.
  • ALLOWED_HOSTS is Django's host allowlist. If your hostname is not in it, every request returns a 400 Bad Request and the logs say Invalid HTTP_HOST header. This is the single most common first-run failure.
  • SITE_ROOT is the public base URL, and it is more load-bearing than it looks: the ping URLs Healthchecks shows you are built from it. Set it to http://localhost:8000 and you will spend an afternoon wondering why every URL you pasted into a crontab points at nothing. Set it to your real HTTPS hostname before you create any checks.

While you are in the file, set the mail settings too — alerts are the point of the exercise, and email is the default delivery route:

DEFAULT_FROM_EMAIL=healthchecks@example.com
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_HOST_USER=<smtp user>
EMAIL_HOST_PASSWORD=<smtp password>
EMAIL_USE_TLS=True

Use a real transactional mail provider rather than trying to send directly from the VPS. A monitoring server sending its own mail from a fresh IP address is a reliable way to have your alerts land in spam, which is worse than no alerts because you think you have them.

Then bring it up and create your account. The createsuperuser command is interactive — it prompts for an email address and a password, so run it in a terminal you can type into, not from a script:

docker compose up -d

Then create your admin account. This one prompts for an email and password, so run it in your own terminal rather than pasting it into a script:

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

One note on the shipped compose file: it builds the image from the checkout rather than pulling a published one, so the first up compiles for a while. If you would rather not build, replace the build: block on the web service with image: healthchecks/healthchecks:latest and pull instead. Building from the clone is fine; it just makes upgrades a git pull plus a rebuild rather than a plain docker compose pull.

Check what actually started:

docker compose ps
docker compose logs --tail 50

You should see the web service and Postgres. Healthchecks also needs a background process that sends the alerts — the shipped compose file runs it. If checks later go red in the UI but nothing ever reaches you, come back to this output: a stack with only web and db running will display failures perfectly and notify nobody.

Healthchecks serves plain HTTP on port 8000 and terminates no TLS of its own. Do not expose that port; the reverse proxy is the only public door.

HTTPS + domain

Point an A record for hc.example.com at the server's public IP, wait for it to resolve, then terminate TLS in front of port 8000. The simplest path is Automatic HTTPS with Caddy:

hc.example.com {
    reverse_proxy 127.0.0.1:8000
}

If you run Caddy as a container, 127.0.0.1 is the proxy's own loopback — put both in one compose project and use reverse_proxy web:8000 instead, dropping the host port publish.

HTTPS is not optional here, for a reason that is specific to this app: ping URLs are effectively secrets, and they travel in the URL itself. Every cron job on every server will be sending them over the wire, repeatedly, forever. Over plain HTTP, anything on the path can read them — and anyone holding a ping URL can mark your backup as successful. A false green is worse than a red, because it is the one state nobody investigates.

Two things follow from that:

  1. Make sure SITE_ROOT matches the HTTPS hostname exactly, including the scheme. Restart the stack after changing it: docker compose up -d.
  2. Treat the ping URLs like API keys in your own configuration management — they belong in the same place you keep other secrets, not in a repo.

First login and hardening

Load https://hc.example.com and sign in with the superuser you created.

Then close the door behind you. Django-based self-hosted apps commonly ship with open registration on, and an internet-facing monitor where anyone can create an account is not what you want. In .env:

REGISTRATION_OPEN=False
docker compose up -d

Verify in a private window that the sign-up route no longer lets a stranger in. If you need to add colleagues, invite them into a project from the UI rather than reopening registration.

The rest of the hardening list is short:

  • Use a strong, unique password on the superuser account. This app knows your entire batch-job map — every backup, every sync, every nightly report.
  • Keep the Django admin closed off. The superuser can reach Django's own admin interface; if you do not use it, there is no reason to leave it discoverable, and a Caddy block returning 404 for that path costs nothing.
  • Keep 8000 and Postgres off the public interface. Neither should appear in sudo ufw status and neither should be published to 0.0.0.0 in the compose file.

Wire up your first checks

A monitor with no jobs pinging it is an empty dashboard. Create a check in the UI, then instrument the job.

Each check has two timings, and getting them right is the difference between useful alerts and noise:

  • Period — how often you expect the ping. Match your actual schedule, or use the cron-expression mode and paste the crontab line itself.
  • Grace time — how late a ping may be before it counts as a failure. Set it to comfortably longer than the job's normal runtime. A backup that usually takes eight minutes but sometimes takes twenty needs a grace period that survives the twenty, or you will get an alert every few weeks that means nothing.

The simplest instrumentation is a curl appended to the cron line:

0 3 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 --retry 5 -o /dev/null https://hc.example.com/ping/YOUR-UUID

The flags matter: -f fails on HTTP errors, -sS stays quiet but still prints real errors, -m 10 caps the request so a hung monitor cannot hang your cron job, and --retry 5 rides out a transient blip. And note the && — the ping only fires if the backup actually succeeded.

For anything more than a one-liner, use the richer endpoints:

URL=https://hc.example.com/ping/YOUR-UUID

curl -fsS -m 10 -o /dev/null "$URL/start"     # job began — enables duration tracking
/usr/local/bin/backup.sh
curl -fsS -m 10 -o /dev/null "$URL/$?"        # exit code: 0 succeeds, anything else fails

Sending the exit code is the upgrade worth making everywhere. Without it, a script that fails but exits cleanly still pings success. With it, Healthchecks records the failure and alerts on it, and the /start ping gives you runtime history so you can see a job creeping from two minutes to nine.

Finally, configure integrations under the project's Integrations tab. Email is already wired if you filled in the SMTP block above; add at least one independent channel that reaches you when email is the thing that is broken — a chat webhook, or a push service like ntfy if you would rather keep that self-hosted too. Then test it deliberately: hit the /fail endpoint on a check and confirm the alert arrives on a real device.

Backups

Postgres holds everything — projects, checks, ping URLs, integrations and the ping log. Losing it means regenerating every check, which also means every UUID changes and every crontab on every server needs editing. Back it up.

cd ~/healthchecks/docker
docker compose exec -T db pg_dump -U postgres hc | gzip > ~/hc-$(date +%F).sql.gz

Confirm the service and user names against docker compose ps and your .env before trusting that line — the shipped compose file names them, and it is worth reading rather than assuming. Keep your .env alongside the dump (it holds SECRET_KEY, without which existing sessions break) and encrypt the pair before it leaves the box, since the dump contains every ping URL you have.

tar czf - ~/hc-$(date +%F).sql.gz .env \
  | gpg --symmetric --cipher-algo AES256 -o ~/hc-$(date +%F).tar.gz.gpg

Copy that off the server. And the irony worth planning for: this is the machine that tells you your backups are running, so its own backup is the one nobody is watching. Give it a check on somebody else's Healthchecks, or on a free hosted account — a monitor monitoring itself is not a monitor.

Upgrades

If you kept the build-from-checkout setup:

cd ~/healthchecks
git pull
cd docker
docker compose build
docker compose up -d

If you switched to the published image, it is the usual pair:

docker compose pull
docker compose up -d

Either way, take a database dump first — Django applies schema migrations on start, and migrations are one-way. Watch docker compose logs -f web on the first boot after an upgrade; migration errors are loud and specific, and a container that restarts in a loop after an upgrade is almost always a migration that did not complete.

Troubleshooting

Every page returns 400 Bad Request. ALLOWED_HOSTS does not include the hostname you are using. Add it, docker compose up -d, and note that it needs the bare hostname — no scheme, no trailing slash.

Ping URLs point at localhost or the wrong host. SITE_ROOT was wrong when you looked. Fix it in .env, restart, and re-copy the URLs from the UI. The check UUIDs do not change, so existing crontabs keep working — only the displayed base URL was wrong.

A check is red but the job definitely ran. Test the ping by hand from the job's own machine: curl -v https://hc.example.com/ping/YOUR-UUID. Common causes are an egress firewall on the job server, DNS resolving differently there, or a grace time shorter than the job's real runtime.

Checks go red in the UI but no alert ever arrives. Two candidates. First, the background sender: docker compose ps should show more than the web and database services. Second, the integration itself — send a test from the integration's own page rather than waiting for a real failure, and check docker compose logs for SMTP rejections.

Alerts arrive but land in spam. You are sending mail from the VPS itself or from an unauthenticated relay. Use a transactional provider with proper authentication, and add a chat or push channel so a mail problem cannot silence you completely.

The container restarts in a loop after an upgrade. Read docker compose logs web. A failed migration or an unreadable SECRET_KEY both look like this, and both say so in the log.

A job reports success even though it failed. The ping is unconditional. Either use && so it only fires on success, or send the exit code with the /$? form above. This is the failure mode that quietly defeats the whole setup, so it is worth auditing every job you have instrumented.

Verification + next steps

You are done when you can: load https://hc.example.com over a valid certificate, log in, confirm registration is closed in a private window, create a check and see a real cron job turn it green on schedule, then deliberately fail that job and receive the alert on a device that is not this browser tab.

From there, the work is coverage. Instrument every scheduled thing you own — backups first, then certificate renewals, database vacuums, report generation, sync jobs, anything with a schedule and no human watching it. Send exit codes rather than bare pings. Then close the other half of the loop with an active monitor: Deploy Uptime Kuma on a VPS covers the outbound side, and together they cover both directions. For the ranked host picks, see Best VPS for Monitoring & Uptime, and the monitoring catalogue has the neighbours.

Next steps

How to self-host HealthchecksBest VPS for Monitoring & UptimeAutomatic HTTPS with CaddyRun Claude Code with Ollama on Your Own VPSDeploy Coolify on a VPSHow to Deploy AnythingLLM on a VPSHow to Deploy authentik on a VPSHow to Deploy Beszel on a VPSHow to Deploy Bitwarden on a VPSHow to Deploy Checkmate on a VPSHow to Deploy docker-mailserver on a VPSHow to Deploy Gatus on a VPSHow to Deploy Gitea on a VPSHow to Deploy Grafana on a VPSHow to Deploy Headscale on a VPSHow to Deploy Home Assistant on a VPSHow to Deploy Immich on a VPSHow to Deploy Jan on a VPSHow to Deploy LibreChat on a VPSHow to Deploy LocalAI on a VPSHow to Deploy Mailcow on a VPSHow to Deploy Mailu on a VPSHow to Deploy n8n on a VPSHow to Deploy NetBird on a VPSHow to Deploy Netdata on a VPSHow to Deploy Nextcloud on a VPSHow to Deploy Next.js to a VPSHow to Deploy Ollama on a VPSHow to Deploy Open WebUI on a VPSHow to Deploy OpenHands on a VPSHow to Deploy Pangolin on a VPSHow to Deploy Passbolt on a VPSHow to Deploy Plausible Analytics on a VPSHow to Deploy Psono on a VPSHow to Deploy Stalwart on a VPSHow to Deploy Supabase on a VPSHow to Deploy TeamPass on a VPSHow to Deploy Twenty CRM on a VPSHow to Deploy Uptime Kuma on a VPSHow to Deploy Vaultwarden on a VPSHow to Deploy wg-easy on a VPSHow to Deploy Zabbix on a VPSDocker & Compose on Ubuntu 26.04Building AI Workflows with n8nInstall Open WebUI with OllamaAdding AI-Powered Insights to Plausible AnalyticsBuilding AI-Powered Apps with Supabase and pgvector

Search SelfHost Atlas

Search apps, comparisons, guides, and categories.

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.