← Monitoring guides

By PingCron · Updated

Docker Cron Job Not Running? Check More Than Container Health

If a Docker cron job is not running, check whether the container started the cron daemon, whether that daemon accepted the crontab, and whether the scheduled command produced a fresh, valid result. A container marked healthy only passed its configured health test. That test might never look at your scheduled job.

We tested four cases with BusyBox 1.37.0: successful work, failed work, stale output despite a zero exit code, and failed reporting after valid work. All four containers were marked healthy when inspected under a deliberately weak probe. Only the validated successful job produced a receipt.

This guide shows the diagnostic order, the crontab ownership problem we reproduced while building the test, and how to report completion without rerunning successful work when monitoring fails.

1. Find the boundary that failed

Start with the last piece of evidence you can actually verify:

What you seeCheck nextWhat it does not prove
Container exited or keeps restartingIts startup command, exit status and logsThat cron ever started
Container runs, but no cron startup or job logThe daemon process and its configured crontab directoryThat installing cron caused it to run
Daemon runs, but the job never appearsFile format, ownership, loaded user and scheduleThat a visible crontab file was accepted
Job starts and exits nonzeroCommand paths, required environment, working directory and workload logsThat changing the schedule will fix the command
Command exits zero, output is staleA validation tied to this run's intended resultThat a nonempty file is today's output
Output is valid, completion receipt is missingReporting transport and destinationThat the business operation must run again

Inspect the container before changing it. These commands are read-only; replace YOUR_CONTAINER with the relevant container name:

docker inspect --format '{{json .State}}' YOUR_CONTAINER
docker inspect --format '{{json .Config.Healthcheck}}' YOUR_CONTAINER
docker logs --tail 100 YOUR_CONTAINER
docker top YOUR_CONTAINER

Use the configured entrypoint and command to identify the scheduler you actually run. A host cron entry that launches a container is a different setup from a cron daemon inside a long-running container. A Kubernetes CronJob is different again; use the Kubernetes CronJob troubleshooting guide for controller, Job and Pod failures.

Do not rerun a payment, import or other mutation merely to obtain another log line. First determine whether the previous attempt committed its work and whether a retry is safe.

2. Read the health check before trusting the badge

Our fixture intentionally used this Docker run option:

--health-cmd true

That probe succeeds independently of the scheduled work. It makes the experiment's blind spot explicit. A check that only proves a web server answers or a process exists may have a similar gap if your real requirement is that a particular export completed by its deadline.

Docker determines health from the command you configure and its outcomes. The command's meaning therefore matters more than the label. Docker HEALTHCHECK reference

A freshness-aware health check can inspect a completion marker, its timestamp and the expected schedule. If your existing monitoring already alerts independently when the job misses its completion deadline, another heartbeat service may duplicate that coverage.

Avoid sending a success heartbeat from an unrelated periodic health probe. The probe can keep reporting while the actual job fails. Put the completion signal after the job's own result checks, or make the probe verify an equivalent job-specific completion condition.

There is also a separate failure-domain question: a check running only inside the stopped container cannot observe its own continued absence. Decide which outside component expects the next completion and alerts when it does not arrive.

3. Make sure cron accepted the file

Our first working daemon still produced no job output. The input crontab was a read-only bind mount created by the CI runner. Its owner inside the container was UID 1001.

BusyBox 1.37.0's crond source loads a crontab only when its owner matches the daemon's required UID, which is 0 in this implementation. A file can be readable and contain a valid schedule yet fail that ownership condition. See miscutils/crond.c, load_crontab, in the matching BusyBox source release.

We corrected the fixture by installing a container-local copy before starting the daemon. The host mount stayed read-only, and its ownership stayed unchanged. This is the relevant excerpt from the tested startup script:

mkdir -p /tmp/cron-tabs
cp /fixture/crontabs/root /tmp/cron-tabs/root
chmod 600 /tmp/cron-tabs/root

Startup ran as root in this disposable fixture, so the new file was owned by UID 0. We verified both values with stat and then started the foreground daemon with its explicit directory:

exec crond -f -l 8 -L /dev/stdout -c /tmp/cron-tabs

The crontab named root contained this user-crontab entry, ending with a newline:

* * * * * /fixture/launch.sh >> /evidence/job.log 2>&1

There is no extra user column in that entry. Do not transplant a Debian /etc/cron.d example with a root field into this BusyBox user-crontab format. Likewise, do not assume another cron implementation uses the same file paths or ownership rules.

The observed startup and execution log included:

source crontab uid=1001
installed crontab uid=0
crond: crond (busybox 1.37.0) started, log level 8
crond: USER root pid 270 cmd /fixture/launch.sh >> /evidence/job.log 2>&1

That last line establishes a scheduled command launch in this run. The result checks below establish what the command did afterward.

The paths above belong to the downloadable synthetic fixture. They are not paths your application already has. For your own image, inspect its daemon, user, permissions and startup lifecycle before adapting the pattern. Do not weaken ownership checks or run a container in privileged mode to make cron accept an otherwise unsuitable configuration.

Another setup failure in our test was simpler: the selected minimal Alpine image did not expose the httpd command required by our local receipt collector. The final experiment uses the official BusyBox image and checks for crond, httpd and wget before scheduling. Test the actual image's available commands instead of assuming every BusyBox-based image includes the same applets.

4. Check the result before sending success

The successful test job wrote a marker containing that invocation's ID. Validation required an exact match to the current ID before reporting. The stale-output case returned zero but left the previous marker untouched, so validation rejected it.

That marker is an intentionally small stand-in for a real business invariant. An export might require the expected date and row count in its destination. A sync might require a committed cursor and a verified destination batch. A backup needs more than a file with yesterday's name: see the PostgreSQL backup and restore walkthrough.

Choose a condition that can distinguish this run's completed result from a leftover artifact. Merely testing that a file exists, a table has rows or a process exited zero may accept stale or incomplete work.

Here are the observed results from November 4, 2026, Pacific time. Each case used an actual once-per-minute cron entry and recorded one scheduled invocation. No manual invocation was used as scheduling evidence.

CaseWork exitOutput validationReportingReceiptsWrapper exitDocker health
Valid work0PassedSucceeded10Healthy
Failed work23Not runNot attempted023Healthy
Stale output0FailedNot attempted042Healthy
Reporting failure0PassedFailed00Healthy

The reporting-failure case is important when deciding what to retry. Its workload completed and its output passed validation. The local reporting request failed, but the wrapper did not execute the workload again. It logged the monitoring failure separately and preserved the successful work result.

This is one possible reporting policy, not a requirement that telemetry failures always exit zero. Choose how your system surfaces those failures, and make sure an outer retry mechanism does not repeat a non-idempotent business operation just because the completion report failed. A dedicated reporting retry should retry the report, with the workload's identity and completion evidence intact.

Missing receipts remain ambiguous: the job may have failed, the host may have stopped, validation may have rejected the output, or reporting may have failed. The alert should prompt investigation using the workload and transport evidence.

5. Inspect or reproduce the experiment

The tested image was the official BusyBox 1.37.0 image on amd64, resolved to this immutable digest:

busybox@sha256:a7dbc319b1438115a207e11930dc4bb3e643dcb3ec0bdd694bb36cd8fad289ec

The fixture's SHA-256 is:

1e4d72039c4f413af3a5523b96f659eaec52e998efacf3c395db119a8171a557

The harness requires Python 3 and Docker on a disposable GitHub-hosted Linux runner. It deliberately refuses an ordinary local invocation. To reproduce it in your own test repository, save it at scripts/docker-cron-evidence.py and run the following command in an explicitly initiated hosted Linux workflow:

python3 scripts/docker-cron-evidence.py --ack-disposable-github-runner

Use a read-only repository token, disable persisted checkout credentials, allow roughly seven minutes, and upload the generated docker-cron-evidence/ directory even if the test fails. Review the source before running it. The acknowledgment flag does not replace the requirement for a disposable environment, and overriding its environment guards is not the reproduction procedure.

The harness pulls the versioned official image tag and records the resolved digest. Compare that recorded value with the digest above before treating a later run as the same image. It starts four workload containers plus an applet-preflight container, keeps the synthetic work isolated, and removes only those named containers. The original run verified removal of all five.

The workload containers have no external network and publish no ports. The HTTP collector listens on loopback inside each container. That setup tests completion-reporting control flow without sending real PingCron events. It is not the recommended location for a production monitor expected to detect host loss.

These are bounded observations, not a reliability benchmark. They do not prove long-term exactly-once execution, Internet delivery, PingCron alert delivery or that a real backup can be restored. They also do not establish how Debian cron, systemd timers or Kubernetes controllers behave.

6. Monitor the next expected completion

Once the job's success condition is explicit, configure an independent expectation for its next completed run. PingCron fits at that boundary: your job reports completion, and the monitor expects the next check-in according to its schedule and grace period. PingCron does not start the container, repair cron, validate your data or rerun the workload.

Start with one job. Match its schedule and timezone, then choose a grace period that accommodates its expected execution window and runtime. Keep different jobs separate so one successful job cannot hide another's missed completion.

Use the heartbeat setup guide for success-only reporting, schedule configuration and a missed-run drill. Verify one real scheduled receipt, verify the alert destination, and deliberately omit a check-in on an isolated test monitor. Restore reporting and confirm recovery before relying on it.

If you already have equivalent independent completion-age monitoring, retain that coverage. If the gap is that a container looks healthy while yesterday's report never finished, create a PingCron monitor for that job.

Related monitoring guides