Running and monitoring cron jobs in Docker
Containers are built to run one long-lived process, while cron jobs are short and periodic. That mismatch causes most of the trouble: jobs that can’t see environment variables, run in the wrong time zone, print their errors nowhere, or quietly stop when a container is replaced. Here are the three common setups and how to make each one dependable.
Three ways to run scheduled jobs
1. Cron on the host runs a command in a container
The host’s crontab calls docker exec in a running container, or starts a one-off container with docker run --rm. It is the simplest option and needs no changes to the image:
# a command in the running "app" service 17 3 * * * docker compose -f /srv/app/compose.yml exec -T app php artisan report:send # a fresh container for each run */10 * * * * docker run --rm --env-file /srv/app/.env registry.example.com/sync:latest
-T turns off the pseudo-terminal, which cron doesn’t have; without it docker compose exec fails. The downside is that the schedule lives on the server, outside your repository.
2. A dedicated scheduler container
Run a separate container from the same image whose only process is a scheduler. Classic cron works, but supercronic fits containers better: it reads a normal crontab, passes the container’s environment variables to jobs and writes their output to stdout, where docker logs sees it.
# compose.yml
services:
app:
image: registry.example.com/app:1.8
scheduler:
image: registry.example.com/app:1.8
command: ["supercronic", "/app/crontab"]
env_file: .env
The crontab file goes into the repository with the code, so schedules are reviewed and deployed like everything else. Install supercronic from its GitHub releases and check the published checksum in your Dockerfile.
3. Cron inside the application container
Possible, but you then need a process manager to run both cron and the app, and one crash can take down either. Prefer option 2.
Traps to avoid
- Environment variables. Classic cron starts jobs with an almost empty environment, so variables passed with
docker run -eorenv_fileare missing inside the job. Use supercronic, or save the environment to a file when the container starts and load it in the job. - Time zone. Containers run in UTC unless told otherwise. Either write schedules in UTC or set
TZ=Europe/Berlin; on Alpine also install thetzdatapackage, or the variable is ignored. - Logs. Cron mails job output, and containers have no mail. Send it to the container’s output: with supercronic that happens by itself, with classic cron running as root append
>> /proc/1/fd/1 2>&1. - The percent sign. In a crontab line
%means a new line. Writedate +\%Finstead ofdate +%F. - Overlapping runs. If a run can take longer than the interval, wrap it in
flock -n /tmp/sync.lockso a second copy doesn’t start.
Know that each run happened
When the scheduler container stops, is left out of a new deployment or loses its crontab, nothing runs and nothing reports an error. Add a ping to every job so silence becomes an alert. Alpine images have BusyBox wget; for curl add it with apk add curl.
# ping only after a successful run */10 * * * * /app/sync.sh && wget -q -T 10 -O /dev/null https://ping.heartcron.com/<uuid> # report the exit code: 0 is success, anything else sends an alert right away */10 * * * * /app/sync.sh; wget -q -T 10 -O /dev/null https://ping.heartcron.com/<uuid>/$?
In Heartcron, give the check the same schedule and time zone as the crontab and a grace period a little longer than the job’s normal run time.