Run scheduled jobs with cron in a Docker container
Build a foreground cron service, use the correct cron file format, and pass selected environment values safely.
Run the scheduler in the foreground so it remains the container's main process. For a Debian-style image, files in /etc/cron.d/ include a user field and are read directly by cron. Do not also install the same file with crontab: that command expects a different, per-user format.
The following example uses a dedicated job container based on the official Python image. It assumes your project supplies requirements.txt and jobs/cleanup.py.
Build the image
FROM python:3.12-slim-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends cron \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN python -m pip install --no-cache-dir -r requirements.txt
COPY jobs/ /app/jobs/
COPY run-job.py /app/run-job.py
COPY app-cron /etc/cron.d/app-cron
COPY --chmod=755 entrypoint.sh /entrypoint.sh
RUN chmod 0644 /etc/cron.d/app-cron
ENTRYPOINT ["/entrypoint.sh"]
Save app-cron with a final newline:
PATH=/usr/local/bin:/usr/bin:/bin
*/10 * * * * root /usr/local/bin/python /app/run-job.py >> /proc/1/fd/1 2>&1
This is a root-run job. The explicit Python path selects the interpreter supplied by this base image. The /proc/1/fd/1 redirection targets the main process's stdout; it must be accessible to the job user, and the container's logging configuration must actually collect it.
Pass selected settings as data
Cron can construct a separate environment for jobs. A raw printenv dump is not a safely quoted shell program, and /etc/environment is not a portable shell script. Serialize only required values instead.
entrypoint.sh:
#!/bin/sh
set -eu
umask 077
python - <<'PYENV'
import json
import os
keys = ("APP_ENV", "DB_HOST", "DB_USER", "DB_PASS")
with open("/run/job-env.json", "w", encoding="utf-8") as file:
json.dump({key: os.environ[key] for key in keys if key in os.environ}, file)
PYENV
exec cron -f
run-job.py:
import json
import os
import sys
with open("/run/job-env.json", encoding="utf-8") as file:
os.environ.update(json.load(file))
os.execv(sys.executable, [sys.executable, "-u", "/app/jobs/cleanup.py"])
Quotes, newlines and dollar signs remain data instead of becoming shell code. The file contains only allowlisted settings and is created with restricted permissions. For non-root jobs, arrange narrowly scoped ownership/readability rather than making secrets public. Prefer supported mounted secret files when the application can read them directly.
Process management and observability
A container can run multiple processes, but starting background cron and then executing an unrelated web application leaves supervision, shutdown and failure propagation unresolved. Separating the scheduler from the web service makes those responsibilities clearer. If they must share a container, deliberately supervise both; do not assume Docker notices that a background scheduler died.
Record job name, start, completion, outcome and duration. After configuring Guance log collection, verify a known event and test both a failed run and a missing completion window. Missing logs can mean scheduling failure, a long-running job, or collection failure.
Alternatives and FAQ
Would Kubernetes CronJob be better? It creates Jobs on a schedule and supports workload configuration, but jobs should still be idempotent and have a chosen concurrency policy. Scheduling is not an exactly-once guarantee. Host cron plus docker exec is another option for a running container; an application scheduler can reuse application configuration but needs coordination across replicas.
What about time zones? Check the scheduler's actual clock and zone. Images commonly use UTC, but do not assume it. Configure the image's timezone data intentionally; a host /etc/localtime bind mount is host-dependent, and setting a job variable does not universally change the daemon's scheduling timezone.
Why did nothing run? Check the cron file's username, permissions, Unix line endings and final newline, then run the exact command manually. Minimal images may have no syslog daemon, so /var/log/syslog need not exist.
How does Alpine differ? Its common BusyBox crond setup uses /etc/crontabs/root, a per-user file without a username field, and runs with crond -f. Check the actual installed implementation rather than mixing its syntax with Debian cron.
See Debian cron behavior, Debian crontab formats, Kubernetes CronJobs.
Contact us
Join the community
to join the community
Try Guance
Start online and pay only for what you use.
Get startedChoose a Guance plan