Effortless Cron Job Monitoring: A Guide to Self-Hosting with Healthchecks.io

Do you ever find yourself lying awake at night, staring at the ceiling, wondering if your beloved cronjobs ran successfully? Worry no more! Today, we’re setting up a free, self-hosted solution to ensure you can sleep like a content little kitten 🐱 from now on.

I present to you Healthchecks.io. According to their website:

Simple and Effective Cron Job Monitoring

We notify you when your nightly backups, weekly reports, cron jobs, and scheduled tasks don’t run on time.

How to monitor any background job:

  1. On Healthchecks.io, generate a unique ping URL for your background job.
  2. Update your job to send an HTTP request to the ping URL every time the job runs.
  3. When your job does not ping Healthchecks.io on time, Healthchecks.io alerts you!

Today, we’re taking the super easy, lazy-day approach by using their Docker image. They’ve provided a well-documented, straightforward guide for deploying it right here: Running with Docker.

What I love most about Healthchecks.io? It’s built on Django, my all-time favorite Python web framework. Sorry, FastAPI—you’ll always be cool, but Django has my heart!

Prerequisites:

  1. A Server: You’ll need a server to host your shiny new cronjob monitor. A Linux distro is ideal.
  2. Docker & Docker Compose: Make sure these are installed. If you’re not set up yet, here’s the guide.
  3. Bonus Points: Having a domain or subdomain, along with a public IP, makes it accessible for all your systems.

You can run this on your home network without any hassle, although you might not be able to copy and paste all the code below.

Need a free cloud server? Check out Oracle’s free tier—it’s a decent option to get started. That said, in my experience, their free servers are quite slow, so I wouldn’t recommend them for anything mission-critical. (Not sponsored, pretty sure they hate me 🥺.)

Setup

I’m running a Debian LXC container on my Proxmox setup with the following specs:

  • CPU: 1 core
  • RAM: 1 GB
  • Swap: 1 GB
  • Disk: 10 GB (NVMe SSD)

After a month of uptime, these are the typical stats: memory usage stays pretty consistent, and the boot disk is mostly taken up by Docker and the image. As for the CPU? It’s usually just sitting there, bored out of its mind.

First, SSH into your server, and let’s get started by creating a .env file to store all your configuration variables:

.env
PUID=1000
PGID=1000
APPRISE_ENABLED=True
TZ=Europe/Berlin
SITE_ROOT=https://ping.yourdomain.de
SITE_NAME=Healthchecks
ALLOWED_HOSTS=ping.yourdomain.de
CSRF_TRUSTED_ORIGINS=https://ping.yourdomain.de
DEBUG=False
SECRET_KEY=your-secret-key

In your .env file, enter the domain you’ll use to access the service. I typically go with something simple, like “ping” or “cron” as a subdomain. If you want to explore more configuration options, you can check them out here.

For my setup, this basic configuration does the job perfectly.

To generate secret keys, I usually rely on the trusty openssl command. Here’s how you can do it:

Bash
openssl rand -base64 64
docker-compose.yml
services:
  healthchecks:
    image: lscr.io/linuxserver/healthchecks:latest
    container_name: healthchecks
    env_file:
      - .env
    volumes:
      - ./config:/config
    ports:
      - 8083:8000
    restart: unless-stopped

All you need to do now is run:

Bash
docker compose -up

That’s it—done! 🎉

Oh, and by the way, I’m not using the original image for this. Instead, I went with the Linuxserver.io variant. There is no specific reason for this —just felt like it! 😄

Important!

Unlike the Linuxserver.io guide, I skipped setting the superuser credentials in the .env file. Instead, I created the superuser manually with the following command:

Bash
docker compose exec healthchecks python /app/healthchecks/manage.py createsuperuser

This allows you to set up your superuser interactively and securely directly within the container.

If you’re doing a standalone deployment, you’d typically set up a reverse proxy to handle SSL in front of Healthchecks.io. This way, you avoid dealing with SSL directly in the app. Personally, I use a centralized Nginx Proxy Manager running on a dedicated machine for all my deployments. I’ve even written an article about setting it up with SSL certificates—feel free to check that out!

Once your site is served through the reverse proxy over the domain you specified in the configuration, you’ll be able to access the front end using the credentials you created with the createsuperuser command.

There are plenty of guides for setting up reverse proxies, and if you’re exploring alternatives, I’m also a big fan of Caddy—it’s simple, fast, and works like a charm!

Here is a finished Docker Compose file with Nginx Proxy Manager:

docker-compose.yml
services:
  npm:
    image: 'jc21/nginx-proxy-manager:latest'
    container_name: nginx-proxy-manager
    restart: unless-stopped
    ports:
      - '443:443'
      - '81:81'
    volumes:
      - ./npm/data:/data
      - ./npm/letsencrypt:/etc/letsencrypt

  healthchecks:
    image: lscr.io/linuxserver/healthchecks:latest
    container_name: healthchecks
    env_file:
      - .env
    volumes:
      - ./healthchecks/config:/config
    restart: unless-stopped

In Nginx Proxy Manager your proxied host would be “http://healthchecks:8000”

If you did not follow my post you will need to expose port 80 on the proxy as well for “regular” Let’s Encrypt certificates without DNS challenge.

Healthchecks.io

If you encounter any errors while trying to access the UI of your newly deployed Healthchecks, the issue is most likely related to the settings in your .env file. Double-check the following to ensure they match your domain configuration:

.env
SITE_ROOT=https://ping.yourdomain.de
ALLOWED_HOSTS=ping.yourdomain.de
CSRF_TRUSTED_ORIGINS=https://ping.yourdomain.de

Once you’re in, the first step is to create a new project. After that, let’s set up your first simple check.

For this example, I’ll create a straightforward uptime monitor for my WordPress host. I’ll set up a cronjob that runs every hour and sends an “alive” ping to my Healthchecks.io instance.

The grace period is essential to account for high latency. For instance, if my WordPress host is under heavy load, an outgoing request might take a few extra seconds to complete. Setting an appropriate grace period ensures that occasional delays don’t trigger false alerts.

I also prefer to “ping by UUID”. Keeping these endpoints secret is crucial—if someone else gains access to your unique ping URL, they could send fake pings to your Healthchecks.io instance, causing you to miss real downtimes.

Click on the Usage Example button in your Healthchecks.io dashboard to find ready-to-use, copy-paste snippets for various languages and tools. For this setup, I’m going with bash:

Bash
curl -m 10 --retry 5 https://ping.yourdomain.de/ping/67162f7b-5daa-4a31-8667-abf7c3e604d8
  • -m sets the max timeout to 10 seconds. You can change the value but do not leave this out!
  • –retry says it should retry the request 5 times before aborting.

Here’s how you can integrate it into a crontab:

Bash
# A sample crontab entry. Note the curl call appended after the command.
# FIXME: replace "/your/command.sh" below with the correct command!
0 * * * * /your/command.sh && curl -fsS -m 10 --retry 5 -o /dev/null https://ping.yourdomain.de/ping/67162f7b-5daa-4a31-8667-abf7c3e604d8

To edit your crontab just run:

Bash
crontab -e

The curl command to Healthchecks.io will only execute if command.sh completes successfully without any errors. This ensures that you’re notified only when the script runs without issues.

After you ran that command, your dashboard should look like this:

Advanced Checks

While this is helpful, you might often need more detailed information, such as whether the job started but didn’t finish or how long the job took to complete.

Healthchecks.io provides all the necessary documentation built right into the platform. You can visit /docs/measuring_script_run_time/ on your instance to find fully functional examples.

Bash
#!/bin/sh

RID=`uuidgen`
CHECK_ID="67162f7b-5daa-4a31-8667-abf7c3e604d8"

# Send a start ping, specify rid parameter:
curl -fsS -m 10 --retry 5 "https://ping.yourdomain.de/ping/$CHECK_ID/start?rid=$RID"

# Put your command here
/usr/bin/python3 /path/to/a_job_to_run.py

# Send the success ping, use the same rid parameter:
curl -fsS -m 10 --retry 5 "https://ping.yourdomain.de/ping/$CHECK_ID?rid=$RID"

As you can see here this will give me the execution time as well:

Here, I used a more complex cron expression. To ensure it works as intended, I typically rely on Crontab.guru for validation. You can use the same cron expression here as in your local crontab. The grace period depends on how long you expect the job to run; in my case, 10 seconds should be sufficient.

Notifications

You probably don’t want to find yourself obsessively refreshing the dashboard at 3 a.m., right? Ideally, you only want to be notified when something important happens.

Thankfully, Healthchecks.io offers plenty of built-in notification options. And for even more flexibility, we enabled Apprise in the .env file earlier, unlocking a huge range of additional integrations.

For notifications, I usually go with Discord or Node-RED, since they work great with webhook-based systems.

While you could use Apprise for Discord notifications, the simplest route is to use the Slack integration. Here’s the fun part: Slack and Discord webhooks are fully compatible, so you can use the Slack integration to send messages directly to your Discord server without any extra configuration!

This way, you’re only disturbed when something really needs your attention—and it’s super easy to set up.

Discord already provides an excellent Introduction to Webhooks that walks you through setting them up for your server, so I won’t dive into the details here.

All you need to do is copy the webhook URL from Discord and paste it into the Slack integration’s URL field in Healthchecks.io. That’s it—done! 🎉

With this simple setup, you’ll start receiving notifications directly in your Discord server whenever something requires your attention. Easy and effective!

On the Discord side it will look like this:

With this setup, you won’t be bombarded with notifications every time your job runs. Instead, you’ll only get notified if the job fails and then again when it’s back up and running.

I usually prefer creating dedicated channels for these notifications to keep things organized and avoid spamming anyone:

EDIT:

I ran into some issues with multiple Slack notifications in different projects. If you get 400 errors just use Apprise. The Discord URL would look like this:

discord://{WebhookID}/{WebhookToken}/

for example:

discord://13270700000000002/V-p2SweffwwvrwZi_hc793z7cubh3ugi97g387gc8svnh

Status Badges

In one of my projects, I explained how I use SVG badges to show my customers whether a service is running.

Here’s a live badge (hopefully it’s still active when you see this):

bearbot

Getting these badges is incredibly easy. Simply go to the “Badges” tab in your Healthchecks.io dashboard and copy the pre-generated HTML to embed the badge on your website. If you’re not a fan of the badge design, you can create your own by writing a custom JavaScript function to fetch the status as JSON and style it however you like.

Here is a code example:

HTML
<style>
    .badge {
        display: inline-block;
        padding: 10px 20px;
        border-radius: 5px;
        color: white;
        font-family: Arial, sans-serif;
        font-size: 16px;
        font-weight: bold;
        text-align: center;
        box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
        transition: background-color 0.3s ease;
    }
    .badge.up {
        background-color: #28a745; /* Green for "up" */
    }
    .badge.down {
        background-color: #dc3545; /* Red for "down" */
    }
    .badge.grace {
        background-color: #ffc107; /* Yellow for "grace" */
    }
</style>
</head>
<body>
<div id="statusBadge" class="badge">Loading...</div>

<script>
    async function updateBadge() {
        // replace this 
        const endpoint = "https://ping.yourdmain.de/badge/XXXX-XXX-4ff6-XXX-XbS-2.json"
        const interval = 60000
        // ---
        
        try {
            const response = await fetch(endpoint);
            const data = await response.json();

            const badge = document.getElementById('statusBadge');
            badge.textContent = `Status: ${data.status.toUpperCase()} (Total: ${data.total}, Down: ${data.down})`;

            badge.className = 'badge';
            if (data.status === "up") {
                badge.classList.add('up');
            } else if (data.status === "down") {
                badge.classList.add('down');
            } else if (data.status === "grace") {
                badge.classList.add('grace');
            }
        } catch (error) {
            console.error("Error fetching badge data:", error);
            const badge = document.getElementById('statusBadge');
            badge.textContent = "Error fetching data";
            badge.className = 'badge down';
        }
    }

    updateBadge();
    setInterval(updateBadge, interval);
</script>
</body>

The result:

It might not look great, but the key takeaway is that you can customize the style to fit seamlessly into your design.

Conclusion

We’ve covered a lot of ground today, and I hope you now have a fully functional Healthchecks.io setup. No more sleepless nights worrying about whether your cronjobs ran successfully!

So, rest easy and sleep tight, little kitten 🐱—your cronjobs are in good hands now.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *