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.
