Pixel Pete, the CanaryHub canaryCanaryHub

How to Run a Cron Job in Docker (and Catch It When It Stops)

How to Run a Cron Job in Docker (and Catch It When It Stops)

Quick answer

There are three practical ways to run a cron job in Docker: bake cron into the image and run it in the foreground as the container's main process, run the schedule on the host and `docker exec` into a running container, or use a container-native scheduler like Supercronic that doesn't daemonize and doesn't strip the environment the way regular cron does. None of them tell you when the job stops running altogether, which is a different failure than an error and needs a separate heartbeat check.

Three patterns cover almost every cron-in-Docker setup: bake cron into the image and run it in the foreground as the container's main process, keep the schedule on the host and docker exec into a running container, or swap in a scheduler built for containers, like Supercronic, instead of fighting regular cron's assumptions. Each one gets you a job that fires on schedule. None of them tell you when that stops happening, which is the failure that actually wakes people up at 2am.

What's the best way to run a cron job in a Docker container?

Bake cron into the image when the job belongs to that container and nothing else needs to run it. On an Alpine base, that's busybox's crond:

FROM alpine:3.20
COPY crontab /etc/crontabs/root
CMD ["crond", "-f", "-l", "2"]

The -f matters more than anything else in that line. Both busybox's crond and standard Debian/Ubuntu cron background themselves by default; -f keeps the process in the foreground instead, which is required any time cron is the container's PID 1 (see below for why).

Run the schedule on the host when the job just needs to reach into an already-running container, not own a process inside it:

* * * * * docker exec my-app php artisan schedule:run >> /var/log/cron.log 2>&1

This avoids putting a second process inside a container that's built around one main process. The tradeoff is that the schedule now lives on the host, so it doesn't travel with the image into a fresh docker run, Compose stack, or Kubernetes deployment.

Use a container-native scheduler like Supercronic when you want the schedule to live inside the image without inheriting regular cron's container-hostile defaults:

FROM alpine:3.20
ADD https://github.com/aptible/supercronic/releases/latest/download/supercronic-linux-amd64 /usr/local/bin/supercronic
RUN chmod +x /usr/local/bin/supercronic
COPY crontab /crontab
CMD ["supercronic", "/crontab"]

Same five-field crontab syntax, but it's designed to be a container's main process from the start: no daemonizing, proper signal handling, and output on stdout where docker logs can see it.

Why does my container exit right after it starts?

Because cron daemonized. crond's own documentation lists -b (background) as the default and -f (foreground) as the opt-in; standard cron behaves the same way. A daemon that forks and exits its parent process is normal on a real host with an init system watching over it, but inside a container there usually isn't one. If cron is PID 1 and it backgrounds itself, the foreground process it started as exits immediately, Docker sees PID 1 exit, and the container stops within a few seconds of docker run, usually before the first job has even had a chance to fire.

The fix is the -f flag above. If you instead background cron deliberately and keep the container alive by tailing a log file, watch for the other half of this problem: that tail process becomes PID 1, and it was never built to reap child processes. Every job cron spawns leaves a zombie behind once it exits, and they accumulate for the life of the container. docker run --init (or ENTRYPOINT ["tini", "--"] built into the image) puts a real init process at PID 1 that reaps those zombies and forwards signals correctly, which is also what Docker's own --init flag exists to solve for any PID 1 that isn't built to handle it.

Why aren't my environment variables available inside the cron job?

Because cron strips almost all of them before running anything. Per the crontab(5) reference, cron sets exactly three variables on its own: SHELL (to /bin/sh), and LOGNAME/HOME from the user's passwd entry. Everything else you passed with docker run -e or a Compose environment: block is visible to your container's main process, but invisible to a command cron launches, since cron builds its own minimal environment rather than inheriting the one the container started with.

The practical fix is to dump the environment to a file before cron starts, then source it from the job itself:

#!/bin/sh
# entrypoint.sh
printenv | sed 's/^\(.*\)$/export "\1"/' > /container.env
exec crond -f -l 2
* * * * * . /container.env && /app/run-job.sh >> /var/log/cron.log 2>&1

Or skip the workaround entirely: this is one of the specific problems Supercronic was built to fix, since it inherits the container's environment instead of purging it.

How do I find out when a Dockerized cron job stops running entirely?

Add a heartbeat ping to the end of the job, after the real work succeeds, aimed at a monitor that expects to hear from it on a schedule and alerts the moment it doesn't. That's the only thing that catches a job that stopped running altogether, as opposed to one that ran and failed, whether the cause is a crashed container that never restarted, a deploy that shipped without the crontab file, or crond itself dying silently.

Alpine's base image doesn't include curl, that's a separate apk add curl away, but it does include wget through busybox, so there's no extra package needed for the ping itself:

# at the end of your job script, only on success
wget --spider -q "https://ingest.canaryhub.ai/ping/YOUR_TOKEN" || true

The || true matters: a flaky network blip on the heartbeat ping shouldn't make the job's own exit code look like a failure. CanaryHub's heartbeat monitors work on the same GET|POST https://ingest.canaryhub.ai/ping/<token> endpoint no matter what's calling it; the token in the URL is the only auth needed. Set the monitor's expected interval to the job's schedule plus a grace period, and it alerts the first time a ping is late, with no second-miss grace period hiding the first one.

A working baseline

Run cron in the foreground if it's your container's main process, fix or sidestep the environment-stripping with a sourced env file or a container-native scheduler like Supercronic, and add --init if anything backgrounds itself and leaves zombies behind. Then ping a heartbeat monitor from the one line at the end of the job that only runs on success. That combination catches every way a Dockerized cron job actually breaks: the container that never starts the job at all, the job that runs with half its configuration missing, and the job that simply stops running one day with nothing in any log to explain why.

Frequently asked questions

Why does my container exit immediately after docker run even though the crontab looks right?
Almost always because cron daemonized. Both regular cron and busybox's crond background themselves by default, so if that's the container's main process (PID 1), the process forks, the parent exits, and Docker sees PID 1 exit and stops the container a few seconds after it started. Run cron in the foreground instead: cron -f for Debian-style cron, crond -f for busybox/Alpine.
Do I need curl inside the container to ping a heartbeat monitor?
No. curl isn't part of the base Alpine image and has to be added with apk add curl, but wget is already there as part of busybox. wget --spider -q <url> sends the ping without pulling in an extra package.
Is Supercronic a drop-in replacement for cron?
For the crontab syntax, yes. For behavior, no, and that's the point: Supercronic doesn't daemonize, so it's safe to run as PID 1; it forwards SIGTERM/SIGINT instead of ignoring them; it logs job output to stdout instead of syslog; and it inherits the container's environment instead of purging it down to SHELL, LOGNAME, and HOME the way regular cron does.
What's the difference between a Docker HEALTHCHECK and a heartbeat monitor for a cron job?
A HEALTHCHECK is Docker's own probe: it runs a command on an interval and reports whether the container itself is healthy, which is about the container's liveness, not about whether a periodic job inside it fired on schedule. A heartbeat monitor is pinged by the job itself after it finishes, and it alerts on a missed ping, which is the only thing that catches a scheduler that silently stopped firing.