How to Deploy Healthchecks on a VPS
Updated Aug 2026
verified on Ubuntu 26.04 · Aug 2026Self-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.
- 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_KEYsigns sessions and tokens. Never share it, never commit it, and do not reuse the example value — a known key means forgeable sessions.DB_PASSWORDis used by both the app and the Postgres container in this compose file, so set it once here and let both read it.ALLOWED_HOSTSis Django's host allowlist. If your hostname is not in it, every request returns a 400 Bad Request and the logs sayInvalid HTTP_HOST header. This is the single most common first-run failure.SITE_ROOTis 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 tohttp://localhost:8000and 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:
- Make sure
SITE_ROOTmatches the HTTPS hostname exactly, including the scheme. Restart the stack after changing it:docker compose up -d. - 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 statusand neither should be published to0.0.0.0in 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.