Heartbeat monitoring for scheduled jobs on a PaaS
Platform cron features tell you about runs that started and failed. They rarely tell you about runs that never started. Add a heartbeat: the job pings a URL when it starts and when it ends, with its exit code. If a ping does not arrive by the scheduled time plus a grace period, or the job runs too long, you get an alert.
What can go wrong with scheduled jobs on a PaaS
- The run never starts. A failed build, a removed schedule, a suspended service or a stopped container means no run and no log line. Failure notifications only fire for runs that happened.
- A run hangs and blocks the next ones. A job that never exits holds its slot. Railway skips the next run while one is still active, and Render delays it until the active run finishes, so one stuck job quietly stops all later ones.
- The job fails but looks fine. A script that catches every error and exits 0, or a shell pipeline that hides a failed command, reports success while doing nothing.
- Wrong time zone. Render and Railway schedules are in UTC, while Coolify uses the server's time zone. A daily job set for 09:00 local time can run hours early or late.
- Killed for memory. The container hits its memory limit and is killed during the run. Depending on the platform this shows as a crash, a failed run or nothing at all.
- No network for the report. The job finished but could not reach the outside world, so its own failure email or webhook never left.
The signals that matter, with alert thresholds
| Signal | Alert when | Why it matters |
|---|---|---|
| Finish ping | none by the scheduled time plus grace | The job did not run, did not finish, or could not reach the network. |
| Exit code ping | any value from 1 to 255 | The job ran and failed; the output you send with it says why. |
| Run time from /start to finish | longer than the max run time you set | A hung run blocks later runs on Render and Railway. |
| Deploy and build status | failed build or crashed container | The next run will use old code or not start at all. |
| Work done per run (by hand) | zero rows, files or emails when there should be some | Make the job exit non-zero when it did nothing, so this becomes an alert. |
Thresholds are starting points. Tune them after a week of data: an alert nobody acts on should be raised or removed.
How to check scheduled jobs on a PaaS by hand
curl -fsS -m 10 https://puzaricloud.in/ping/<token>/start && sleep 5 && curl -fsS -m 10 https://puzaricloud.in/ping/<token>/0
command -v curl wget python3 python node
railway cron list
docker ps --format '{{.Names}} {{.Status}}' | grep -i <app-name>
Replace <token> with the token from your heartbeat in Vigil. A ping returns OK; an unknown token returns 404.
How each platform runs scheduled jobs
| Platform | Where the schedule lives | Time zone | When a run is still going | Put the wrapper in |
|---|---|---|---|---|
| Render | A Cron Job service with a Schedule and a Command | UTC | At most one run is active; the next scheduled run waits until it finishes. Render stops a run after 12 hours. | The Command field, or the Docker Command under Advanced for image-based jobs |
| Railway | The service's Cron Schedule setting; Railway runs the start command | UTC; runs at least 5 minutes apart | If the previous run is still Active, the new run is skipped. A job that never exits blocks every later run. | The service's start command |
| Coolify | Scheduled Tasks on the application or service; the command runs inside its container | The time zone set on the server | Check your version's behaviour; the heartbeat's max run time catches long runs either way. | The task command, wrapped in sh -c '...' |
| Dokploy | Schedule Jobs: application and compose jobs run with docker exec, server jobs run on the host | The server's clock | The target container must be running, or the job fails to start. | The job command, or the server script |
From each platform's documentation on 20 October 2026. Platforms change; when a row matters to you, check the current docs.
One wrapper script for every platform
Put a tiny script in your repository and run your real command through it. It pings /start, runs the job, then pings /<exit code>: 0 marks the run as a success, 1 to 255 as a failure. The job's output goes to the platform's log as before and is also stored with the run.
#!/bin/sh # heartbeat.sh: ping /start, run the job, then report its exit code. # usage: sh heartbeat.sh python manage.py send_digests URL="https://puzaricloud.in/ping/$HEARTBEAT_TOKEN" curl -fsS -m 10 --retry 3 -o /dev/null "$URL/start" "$@" > /tmp/job.log 2>&1 code=$? cat /tmp/job.log # the last 100 KB of output is stored with the run in Vigil tail -c 100000 /tmp/job.log | curl -fsS -m 10 --retry 3 -o /dev/null --data-binary @- "$URL/$code" exit $code
Set HEARTBEAT_TOKEN as an environment variable on the service. Then the platform command becomes, for example, sh heartbeat.sh python manage.py send_digests on Render or Railway, or sh -c 'sh /app/heartbeat.sh php artisan reports:daily' in a Coolify scheduled task or a Dokploy schedule job.
The --retry 3 matters: one network blip at the end of a long job should not turn into a false "missed run" alert. The -m 10 time limit makes sure a slow network never keeps the job alive.
# Alpine and BusyBox images have wget wget -q -T 10 -O /dev/null "https://puzaricloud.in/ping/$HEARTBEAT_TOKEN" # slim Python images have neither: use Python itself python -c "import sys, urllib.request; urllib.request.urlopen(sys.argv[1], timeout=10)" "https://puzaricloud.in/ping/$HEARTBEAT_TOKEN/$code" # Node 18 and later node -e "fetch(process.argv[1]).catch(() => process.exit(1))" "https://puzaricloud.in/ping/$HEARTBEAT_TOKEN"
The ping URLs follow the Healthchecks.io format, so existing Healthchecks client libraries and scripts work by changing the base URL.
How to watch scheduled jobs on a PaaS with Vigil
- In New monitor create a Heartbeat and choose Cron expression. Paste the job's schedule and set the time zone to match the platform: UTC for Render and Railway, your server's time zone for Coolify.
- Set a grace period longer than a normal run, for example 10 minutes for a job that takes 2, so a slow run is not reported as missed.
- Set Max run time. It needs the
/startping, which the wrapper sends, and alerts when a run is still going after that many minutes. - Copy the ping URL, https://puzaricloud.in/ping/<token>, put its token in
HEARTBEAT_TOKENon the service, and run the job through the wrapper script above. - Trigger one run by hand (Render: Trigger Run, Railway:
railway cron run, Coolify: Execute Now) and check that the heartbeat shows the start and finish pings. - Pick alert channels that do not depend on the platform, such as Telegram, Slack, email or on-call paging.
Every alert goes to the channels you pick: email, Slack, Telegram, Discord, Microsoft Teams, Pushover, webhooks or on-call paging. Failures are confirmed by a second check before anyone is woken up.
scheduled jobs on a PaaS monitoring FAQ
Do I still need the platform's own failure emails?
Keep them, they cost nothing. But they only cover runs that started. The heartbeat also covers runs that never started, runs that hung and runs whose failure report could not be sent.
What grace period should I use?
Longer than your slowest normal run plus a few minutes. A job that usually takes 3 minutes and sometimes 8 is fine with a grace of 15. If the alert comes too late for you, lower the grace and use max run time to catch hung runs.
My Railway cron runs every 5 minutes. Is a heartbeat too noisy?
No. A heartbeat stays silent while pings arrive on time and alerts once when they stop, then once when they resume. Railway's minimum gap is 5 minutes, so set the heartbeat's cron to the same schedule.
Can I use this without changing my code?
Yes. The wrapper runs your existing command unchanged; only the platform's command field changes. Heartbeats with a 1-minute period are on the free plan.