Self-host Healthchecks.io to monitor cron jobs
Follow this on your own server. Start free, no card needed
A cron job that silently stops running can’t tell you it stopped. The script just doesn’t execute one night, nothing crashes, nothing logs an error, and three weeks later you find out the hard way that backups haven’t run since the server’s kernel got updated and the cron daemon never came back. Healthchecks.io is a dead man’s switch built for exactly this: each of your scheduled jobs pings a unique URL when it finishes, and if a ping doesn’t show up on the schedule you told Healthchecks to expect, it alerts you by email, Slack, a webhook, or any of the other integrations it supports. This guide sets up a self-hosted instance on your own server with Postgres, HTTPS through Caddy, and real alert email, then shows the one-click path if you’d rather skip the manual setup.
What you’ll need
- A server. A Hetzner CX23 (2 vCPU, 4 GB memory, 40 GB NVMe) at €5.99/month excluding VAT is enough for Healthchecks, its own Postgres, and a reverse proxy running together on one box for a personal setup or a small team’s worth of cron jobs.
- A domain name (or a subdomain of one you already own), with access to its DNS settings.
- Docker and the Docker Compose plugin installed on the server (covered in Step 2).
- An SMTP account or relay you can send mail through, since alert emails are the entire point of running this.
- A terminal and SSH access to the server.
Step 1: Create the server and point a domain at it
Spin up an Ubuntu 24.04 server on Hetzner’s CX23 plan (2 vCPU, 4 GB memory, 40 GB NVMe, €5.99/month excluding VAT). Note its public IP address, then add your SSH key during creation or immediately after so you’re not relying on a password.
In your domain’s DNS settings, add an A record pointing a subdomain, for example checks.yourdomain.com, at that IP:
Type: A
Name: checks
Value: <your server's IP>
TTL: 300
Confirm it resolves before moving on:
dig checks.yourdomain.com +short
That should print your server’s IP, not an old one and not nothing. While you’re there, lock down the firewall to only what this stack needs:
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
Step 2: Install Docker
SSH into the server and install Docker Engine plus the Compose plugin:
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
Log out and back in for the group change to take effect, then confirm both pieces are present:
docker --version
docker compose version
Step 3: Lay out the project and its secrets
Create a directory for the stack:
mkdir -p ~/healthchecks && cd ~/healthchecks
Generate a random secret key and a database password. Healthchecks uses SECRET_KEY to sign sessions, so a weak or reused value is a real problem, not a formality:
openssl rand -base64 32
openssl rand -base64 24
Create .env with the project’s own configuration keys, filled in for your domain:
# .env
DOMAIN=checks.yourdomain.com
DB=postgres
DB_HOST=db
DB_PORT=5432
DB_NAME=healthchecks
DB_USER=postgres
DB_PASSWORD=<the password you generated>
ADMINS=you@yourdomain.com
DEFAULT_FROM_EMAIL=healthchecks@yourdomain.com
SITE_ROOT=https://checks.yourdomain.com
SITE_NAME=Healthchecks
PING_ENDPOINT=https://checks.yourdomain.com/ping/
REGISTRATION_OPEN=False
SECRET_KEY=<the key you generated>
GUNICORN_WORKERS=4
GUNICORN_PORT=8000
A few of these matter more than they look:
SITE_ROOTis the full HTTPS URL of your instance, not just the bare domain. Healthchecks uses it to build every link it shows you or emails you, including the ping URLs themselves.PING_ENDPOINTshould matchSITE_ROOTwith/ping/appended. It’s a separate setting because some installs serve ping traffic from a different, lighter-weight path than the main UI; for this single-container setup they share the same domain.REGISTRATION_OPEN=Falsecloses public sign-up on your instance. It defaults to open, which is the one setting in this file you don’t want to forget, since an open registration page on a public domain is an invitation for strangers to create accounts on your server.DOMAINis a second, bare-domain form of the same value, used by the Caddyfile in Step 5. Healthchecks itself never readsDOMAIN; it readsSITE_ROOT.
You’ll also need SMTP settings so Healthchecks can actually send you alert emails, since a monitoring tool that can’t email you when a job goes quiet isn’t doing its job. Add EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD, EMAIL_PORT and EMAIL_USE_TLS to the same .env file, pointed at whatever SMTP provider or relay you already use. The project’s own self-hosted configuration docs cover every email-related variable in detail if your provider needs something beyond the basics.
Set permissions so only your user can read the file:
chmod 600 .env
Step 4: Write the Docker Compose file
Healthchecks’ own docker/docker-compose.yml, from the project’s GitHub repository, is written to build the image locally from docker/Dockerfile and publish port 8000 straight to the host, which is the right shape for running it on your own machine but not for a server facing the internet. This setup adapts it three ways: it uses the pre-built healthchecks/healthchecks image instead of building from source, pins an exact version tag instead of latest so an unattended restart can’t silently move you to a new release, and doesn’t publish 8000 to the host at all, since Caddy fronts it instead.
Create docker-compose.yml:
services:
db:
image: postgres:17-alpine
restart: unless-stopped
volumes:
- db-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=${DB_NAME}
- POSTGRES_PASSWORD=${DB_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 5s
retries: 10
networks:
- healthchecks-net
web:
image: healthchecks/healthchecks:v5.0.1
restart: unless-stopped
env_file:
- .env
depends_on:
db:
condition: service_healthy
networks:
- healthchecks-net
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
- DOMAIN=${DOMAIN}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- web
networks:
- healthchecks-net
networks:
healthchecks-net:
volumes:
db-data:
caddy_data:
caddy_config:
postgres:17-alpine replaces the plain postgres:16 the project’s own example uses; both work fine, 17 is simply the more current release at the time of writing. POSTGRES_USER is left unset deliberately, which makes Postgres default to a postgres user, matching DB_USER=postgres in .env without repeating it. Only Caddy publishes ports to the host; db and web are reachable only on the internal Docker network.
Step 5: Write the Caddyfile and start everything
Create Caddyfile in the same directory:
{$DOMAIN} {
reverse_proxy web:8000
}
Start the stack:
docker compose up -d
Watch Caddy pick up a certificate:
docker compose logs -f caddy
You should see a line about a certificate obtained for your domain, then https://checks.yourdomain.com should load with a valid padlock and no warning.
Step 6: First-run setup and closing registration
Visiting your domain for the first time shows Healthchecks’ own sign-up page. There’s no separate CLI step to create an admin account here; the first account you register is the account you’ll use to manage checks, projects and integrations going forward. Register it now, log in, and create your first check to confirm pings reach it.
Once that account exists, make sure REGISTRATION_OPEN=False is set in .env (it already is, from Step 3) and redeploy the web service so the setting takes effect if you changed anything:
docker compose up -d web
From this point on, visiting the sign-up page on your instance shows a closed-registration message instead of a working form, so nobody else can create an account on your server.
Step 7: Back up the database and the secret key
The database holds every project, check, integration and ping history you have. A daily Postgres dump, kept off the server:
docker compose exec -T db pg_dump -U postgres healthchecks > "healthchecks-backup-$(date +%F).sql"
Put that in a cron job (ironically, one you could have Healthchecks itself monitor) and copy the result somewhere other than this server, since a backup that only lives on the server it’s backing up doesn’t survive the failure mode that actually destroys a server.
Back up .env too, kept somewhere separate and access-controlled. Its SECRET_KEY signs every session Healthchecks issues; losing it just means everyone gets logged out and has to sign in again, which is a minor annoyance, but a leaked copy of it is a way for someone else to forge a session, so treat it with the same care as the database password.
Step 8: Upgrade cleanly
Check the release notes on the project’s GitHub repository between the tag you’re running and the one you’re moving to, then bump the version in docker-compose.yml:
image: healthchecks/healthchecks:<new-version>
Pull and restart just that service:
docker compose pull web
docker compose up -d web
Check the logs for a clean startup and a successful migration step:
docker compose logs web
The pre-built image is expected to run any pending database migrations automatically as part of its own startup, so a clean set of logs with no migration error is what you’re looking for; if anything does fail, your Postgres backup from Step 7 is the way back to the previous version.
The shortcut: do it with Railyard
Everything above, a Postgres-backed Healthchecks instance with a generated secret key and HTTPS already wired up, is also a one-click template:
https://app.railyard.run/deploy?template=healthchecks
Railyard builds it straight from the docker/Dockerfile in the project’s own repository, provisions a managed Postgres database for it automatically, and runs the project’s own migration command as a release step before every deploy, the same python manage.py migrate you’d run by hand otherwise. It also sets ALLOWED_HOSTS=* and generates a real SECRET_KEY for you, so there’s no secret to invent and paste in yourself.
If you’d rather start from Railyard generally before deciding on Healthchecks specifically, the quickstart covers the basics, deploy button explains the ?template= link format if you want to embed one elsewhere, and databases and backups covers how backups work across templates generally, not just this one. Railyard’s Free plan covers one server plus a database server and three apps with no card required, Pro is $19/month per team, and Team is $49/month per team.
Troubleshooting
Alert emails never arrive. This is almost always missing or wrong SMTP settings, not a Healthchecks bug. Check EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD, EMAIL_PORT and EMAIL_USE_TLS are all set in .env and that the account they describe can actually send mail; many providers block the first send from a new server or IP until you verify it. A monitoring tool whose own alert path is broken will quietly fail at the one job it exists to do, so send yourself a test check-in failure and confirm the email actually lands before relying on this for anything real.
Ping URLs shown in the UI point at the wrong domain. SITE_ROOT doesn’t match the real HTTPS address you’re serving the app from. Fix it in .env and redeploy web; every ping URL Healthchecks has already shown you or put in an email was built from the old value and needs to be re-copied from the UI after the fix.
Bad Request (400) when loading the site. ALLOWED_HOSTS doesn’t include the domain you’re visiting. The compose setup here sets it to * via the project’s own default handling, so this usually shows up only if you’ve changed that behavior; check it matches your real domain if you have.
Postgres connection refused, or the web container keeps restarting. Check docker compose logs db first for the actual error. A mismatched password between DB_PASSWORD in .env and what Postgres was initialized with is the most common cause, usually from editing .env after the db-data volume already exists; Postgres only reads POSTGRES_PASSWORD on the very first startup with an empty data directory, so a later change needs either a fresh volume or an in-database password change to match.
HTTPS never issues, or the browser shows a certificate warning. Run dig checks.yourdomain.com +short and confirm it returns this server’s IP. Let’s Encrypt also needs port 80 reachable from the internet for its validation step, so check ufw status shows both 80 and 443 open, not just 443.
FAQ
Do I need Postgres, or does SQLite work? Healthchecks supports SQLite for small, low-traffic installs, but Postgres is the better default the moment more than one or two people depend on this instance staying up, since it handles concurrent writes and larger ping histories more gracefully. This guide uses Postgres from the start for that reason.
How do I actually point a cron job at this once it’s running? Append a curl call to the end of the job itself, after the command you’re monitoring succeeds:
your-backup-script.sh && curl -fsS -m 10 --retry 5 https://checks.yourdomain.com/ping/<uuid>
Each check in the UI gives you its own unique ping URL to copy into the job. If the script fails before reaching that line, no ping arrives, and that’s exactly the signal Healthchecks is waiting for.
What’s the real difference between this and the hosted healthchecks.io? Functionally, very little; it’s the same open-source application either way. The hosted version is maintained for you and billed per month once you’re past its free tier; a self-hosted instance costs only the server it runs on, with no limit on the number of checks, but you’re the one keeping it patched and backed up.
Should REGISTRATION_OPEN really be closed? Yes, once your own account exists. There’s no reason for a self-hosted monitoring instance on your own domain to accept sign-ups from anyone who happens to find the URL, and leaving it open is the one setting in this whole guide that’s genuinely a mistake to skip.
How much does this cost to run? The server is €5.99/month excluding VAT. Healthchecks itself is free, open-source software with no per-check or per-integration charge; you’re paying for the box it runs on, not for what runs on it.
A cron job that fails loudly is easy to notice. The one that fails silently, the backup that stopped three weeks ago, the certificate renewal that quietly stopped firing, is the one this is actually for, and now you have your own instance watching for exactly that.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.