Last updated: 5 November 2026 · Applies to Debian-based images (python:3.13-slim, Debian 13 trixie) with Debian cron 3.0pl1, Alpine 3.24, supercronic 0.2.51, Docker Compose v2+
You want a script to run every night, or every five minutes, next to your app in Docker. This guide shows how to run a cron job in a Docker container so that it actually fires, sees the same environment variables as your app, prints to docker logs, never runs twice at once, and stops cleanly on docker stop.
Why it matters: cron inside a container fails silently. No error, no log line, the job just never runs, and you find out a week later when the backups are missing. Many popular guides stop at CMD cron && tail -f, which hides exactly the problems that bite in production.
Try it on Hubfly space: deploy the Compose file below as a two-container stack.
Run a cron job in a Docker container: the short answer
Run cron as its own service built from the same image as your app. Start it with cron -f, save the container's environment to a file at startup, send job output to /proc/1/fd/1, wrap each job in flock, and set init: true so docker stop works. Everything below is copy-paste ready.
Pick the right approach
| Approach | Good for | Watch out for |
|---|---|---|
Host crontab + docker exec | One server you control | Breaks on any PaaS or Kubernetes, lives outside your repo |
| Cron inside the app container | Quick hacks | Two processes in one container: if cron dies, nobody notices |
| Separate cron container, same image | Most apps (this guide) | Must run exactly one copy |
| supercronic instead of cron | New images, minimal setup | Extra binary to download and pin |
| Scheduler in your app (Celery beat, node-cron, APScheduler) | Apps that already run a worker | Ties scheduling to app code and its deploys |
The separate container wins for most teams: the schedule lives in Git, it restarts on its own, and its logs sit right next to your app's.
Step 1: Lay out the files
shop-api/ ├── app/ # your application code ├── jobs/ │ └── cleanup_sessions.py ├── docker/ │ ├── crontab │ └── cron-entrypoint.sh ├── Dockerfile └── compose.yaml A stand-in job so you can see it work. Replace it with your real script:
# jobs/cleanup_sessions.py import os from datetime import datetime, timezone db = os.environ.get("DATABASE_URL", "<missing>") print(f"{datetime.now(timezone.utc):%Y-%m-%d %H:%M:%S} cleanup_sessions: connecting to {db.split('@')[-1]}", flush=True) Step 2: Write the crontab for a Docker container
Why: the crontab format inside /etc/cron.d/ has strict rules, and cron ignores a file that breaks them without printing anything.
# docker/crontab (installed as /etc/cron.d/shop-jobs) SHELL=/bin/sh # Every 5 minutes: clean up expired sessions */5 * * * * root flock -n /tmp/cleanup_sessions.lock /usr/local/bin/run-job python /app/jobs/cleanup_sessions.py >> /proc/1/fd/1 2>> /proc/1/fd/2 # Every night at 02:30: date-stamped log line (note the escaped %) 30 2 * * * root echo "nightly run $(date +\%F)" >> /proc/1/fd/1 2>&1 Five rules decide whether these lines ever run:
- A user column. Files in
/etc/cron.d/need a user (rootabove) between the schedule and the command. A line without it is skipped. - The file name has no dot. Debian cron ignores
/etc/cron.d/files whose names contain a dot.shop-jobsruns;shop-jobs.cronnever does. - A trailing newline. If the last line doesn't end with a newline, cron drops it. Keep an empty line at the end of the file.
- Permissions 0644, owned by root. Cron rejects files that the group or others can write (for example 0664).
- Escape every
%as\%. In a crontab,%means "newline, and send the rest to stdin".date +%Fsilently breaks the whole command.
Two more habits that save hours:
- Use absolute paths. Cron's default
PATHis just/usr/bin:/bin, sopythonfrom/usr/local/binis not found. Therun-jobwrapper in Step 3 fixes this, but absolute paths make the crontab readable on its own. -
flock -nstops overlapping runs. If the previous run still holds the lock, the new one exits at once instead of piling up.
Step 3: Pass environment variables to cron jobs
Why: cron starts every job with an almost empty environment. Your DATABASE_URL, API keys and TZ, set with environment: or -e, are not visible to the job. This is the number one reason a cron job "works when I run it by hand".
Save the environment once at container start, then load it before each job:
#!/bin/sh # docker/cron-entrypoint.sh set -eu # Save the container's environment, safely quoted, readable only by root export -p > /run/cron.env chmod 600 /run/cron.env exec cron -f #!/bin/sh # /usr/local/bin/run-job (created in the Dockerfile) . /run/cron.env exec "$@" export -p writes each variable as export NAME='value' with proper quoting, so passwords with spaces, quotes or @ survive. That's safer than the popular env >> /etc/environment, which breaks on quotes and appends duplicates on every restart. Because the snapshot also restores the container's PATH, python resolves inside jobs.
Keep the file at mode 600: it holds your secrets.
Step 4: The Dockerfile
# Dockerfile FROM python:3.13-slim RUN apt-get update \ && apt-get install -y --no-install-recommends cron tzdata curl ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . # Cron files: no dot in the name, 0644, root-owned COPY docker/crontab /etc/cron.d/shop-jobs RUN chmod 0644 /etc/cron.d/shop-jobs \ && printf '#!/bin/sh\n. /run/cron.env\nexec "$@"\n' > /usr/local/bin/run-job \ && chmod 0755 /usr/local/bin/run-job COPY docker/cron-entrypoint.sh /usr/local/bin/cron-entrypoint RUN chmod 0755 /usr/local/bin/cron-entrypoint # The app is the default; the cron service overrides this CMD ["gunicorn", "app.main:app", "--bind", "0.0.0.0:8000"] One image, two jobs: the web container runs CMD, the cron container runs the entrypoint. Both always ship the same code. Replace the requirements.txt lines and CMD with your app's own install and start commands. curl is there for the heartbeat ping further down.
Step 5: Add a cron service to Docker Compose
# compose.yaml services: web: build: . environment: DATABASE_URL: ${DATABASE_URL} TZ: Africa/Kigali ports: - "8000:8000" restart: unless-stopped cron: build: . command: ["/usr/local/bin/cron-entrypoint"] init: true environment: DATABASE_URL: ${DATABASE_URL} TZ: Africa/Kigali stop_grace_period: 30s restart: unless-stopped What each setting does:
-
init: trueputs a tiny init process in front of cron. Without it, cron is process 1, and process 1 ignoresSIGTERMbecause cron sets no handler for it. Everydocker stopthen waits the full grace period and ends with aSIGKILL. -
TZsets the time zone for the schedule. Cron reads it at startup, so30 2 * * *means 02:30 Kigali time here. The image needstzdata, which the Dockerfile installs. LeaveTZout and schedules run in UTC. -
stop_grace_period: 30scaps how long Docker waits before it force-kills the container. A job that is mid-run is still interrupted, so make every job safe to re-run. - No
portson the cron service. It serves nothing.
Put your secrets in a .env file next to compose.yaml (and in .gitignore):
DATABASE_URL=postgres://shop:change-me@orders-db:5432/shop Add a .dockerignore so COPY . . never bakes that file into the image:
# .dockerignore .env .git Step 6: Start it and check the logs
docker compose up -d --build docker compose logs -f cron Within five minutes you should see:
cron-1 | 2026-11-05 09:35:01 cleanup_sessions: connecting to orders-db:5432/shop Run a job by hand with the same environment cron uses:
docker compose exec cron run-job python /app/jobs/cleanup_sessions.py Check that cron loaded your file and sees the right time zone:
docker compose exec cron ls -l /etc/cron.d/ docker compose exec cron date Why /proc/1/fd/1: Docker collects only what process 1 writes to stdout and stderr. Cron's own children don't write there, so their output vanishes (cron tries to email it, and a container has no mail server). Appending to process 1's stdout puts each job's output in docker logs, with no log file filling up the disk. Use >>, not >: if stdout is ever a file, > truncates it on every run.
Alpine images: use busybox crond
On Alpine, crond comes from BusyBox and behaves differently:
- The crontab goes in
/etc/crontabs/rootwith no user column. - It keeps the container's environment, so you can skip the snapshot step.
- Start it in the foreground with logging:
crond -f -l 2.
# docker/crontab-alpine (installed as /etc/crontabs/root; keep the empty last line) */5 * * * * /app/jobs/cleanup.sh >> /proc/1/fd/1 2>> /proc/1/fd/2 FROM alpine:3.24 RUN apk add --no-cache tzdata COPY jobs/ /app/jobs/ COPY docker/crontab-alpine /etc/crontabs/root CMD ["crond", "-f", "-l", "2"] A matching job script (inside a script, % needs no escaping):
#!/bin/sh # jobs/cleanup.sh set -eu echo "$(date -u '+%F %T') cleanup: ok" Make it executable (chmod +x jobs/cleanup.sh) before you build. BusyBox crond has no built-in overlap protection; if a job can run longer than its interval, use supercronic (below).
Keep init: true in Compose here too.
Option: supercronic, a cron built for containers
supercronic runs a normal crontab (no user column) in the foreground, keeps the environment, writes each job's output to the container's log stream, waits for a job to finish before starting the same job again, and shuts down gracefully on SIGTERM. Install it with the checksum its release page publishes (version 0.2.51, amd64):
ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.2.51/supercronic-linux-amd64 \ SUPERCRONIC_SHA1SUM=1692066e350a88ff4d7d6474fe3c7a6c41ec5e89 \ SUPERCRONIC=supercronic-linux-amd64 RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ && curl -fsSLO "$SUPERCRONIC_URL" \ && echo "${SUPERCRONIC_SHA1SUM} ${SUPERCRONIC}" | sha1sum -c - \ && chmod +x "$SUPERCRONIC" \ && mv "$SUPERCRONIC" /usr/local/bin/supercronic \ && rm -rf /var/lib/apt/lists/* COPY docker/crontab-supercronic /app/crontab CMD ["supercronic", "/app/crontab"] For ARM servers, use the supercronic-linux-arm64 asset and its checksum from the same release page. Run supercronic -test /app/crontab to check the syntax before you ship.
Know when a job didn't run
A cron job that silently stops is worse than one that fails loudly. Ping a dead man's switch (any uptime or heartbeat monitor) only after success:
0 3 * * * root flock -n /tmp/backup.lock /usr/local/bin/run-job sh -c 'python /app/jobs/backup.py && curl -fsS -m 10 "$HEARTBEAT_URL"' >> /proc/1/fd/1 2>> /proc/1/fd/2 If the ping doesn't arrive on time, the monitor alerts you. Set HEARTBEAT_URL in the cron service's environment.
Run the cron container on Hubfly space
Deploy web and cron on Hubfly space as two containers from the same image or repository: web keeps the default command, and cron runs /usr/local/bin/cron-entrypoint with the same environment variables. Import the Compose file with the Stack Composer, and check that the cron service keeps that command after import. Two settings matter for a scheduler:
- Keep it always on. Shared-tier containers auto-sleep when idle, and a sleeping container runs no jobs. Put the
croncontainer on the Dedicated tier with 24×7 on and Auto Sleep off. - Run exactly one copy. Don't add replicas to the
croncontainer: each replica runs the full schedule, so every job fires once per copy. Scalewebinstead.
Job output shows up in the container's Logs tab, with stderr lines marked ERR. The smallest always-on scheduler (0.5 vCPU, 256 MB, 1 GB disk) costs about $1.84 a month: 0.5 × $0.004 × 730 h = $1.46 for CPU, 0.25 × $0.002 × 730 h = $0.37 for memory, and about $0.01 for storage.
What can go wrong
| Symptom | Cause | Fix |
|---|---|---|
| Job never runs, no error anywhere | File name has a dot, last line has no newline, missing user column, or file mode isn't 0644 | Rename to shop-jobs, add an empty last line, add root, chmod 0644 |
| Works by hand, fails in cron | Cron strips environment variables and PATH | Use the export -p snapshot and run-job wrapper |
python: not found | Cron's PATH is /usr/bin:/bin | Absolute paths, or the run-job wrapper |
Command with date +%F does nothing | % is special in crontabs | Write \% |
docker logs is empty | Job output isn't sent to process 1 | Append >> /proc/1/fd/1 2>> /proc/1/fd/2 |
docker stop hangs for the whole grace period, then kills | Cron is process 1 and ignores SIGTERM | init: true (or docker run --init) |
| Jobs run at the wrong hour | Container runs in UTC | Set TZ and install tzdata |
| Job runs twice | Two cron containers or replicas, or a slow run overlapping the next | One scheduler only, plus flock -n |
FAQ
Why is my cron job not running in Docker?
Usually because cron ignored the file: a dot in the name under /etc/cron.d/, no newline at the end, no user column, or permissions other than 0644. The other common cause is cron not running at all: the container's command must be cron -f, not cron (which goes to the background, so the container exits).
How do I pass environment variables to a cron job in Docker?
Cron doesn't pass them on. Save them at startup with export -p > /run/cron.env and source that file before each job, as in Step 3. Or use BusyBox crond or supercronic, which keep the environment.
How do I see cron job output with docker logs?
Append the job's output to process 1: >> /proc/1/fd/1 2>> /proc/1/fd/2. Docker only captures process 1's stdout and stderr.
Should cron run in the same container as my app?
No. One process per container keeps restarts and logs simple. Run a second service from the same image with a different command, as in Step 5.
How do I set the time zone for cron in a Docker container?
Set TZ (for example TZ=Europe/Berlin) on the cron container and install tzdata in the image. Cron reads the time zone at startup, so restart the container after changing it.
How do I stop a cron job from running twice at the same time?
Wrap it in flock -n /tmp/job-name.lock. If the previous run still holds the lock, the new run exits at once. supercronic does this by default.
Can I use docker exec from the host's crontab instead?
Yes, on a single server: */5 * * * * cd /srv/shop-api && docker compose exec -T web python /app/jobs/cleanup_sessions.py. Using the Compose service name survives container recreation, but the schedule still lives outside your repo and doesn't move with your app to another host or platform.
Is supercronic better than cron in Docker?
For new images, it's simpler: no user column, environment kept, job output in docker logs, no overlap. Debian cron is fine when you already use a Debian-based image and follow the rules above.
Related guides
- Docker health checks that catch real problems
- Run database migrations as a Compose job before your app starts
- Build failed? How to read the build log and fix it fast
Go further on Hubfly space
- Deploy the whole stack from Compose. The Compose reference shows how services, volumes and health checks map to containers.
- Rebuild web and cron together on every push. Hook your repo up with deployment pipelines so both containers always run the same commit.
- Scale the web container, not the scheduler. Scaling covers auto-scale and replicas for the containers that serve traffic.
By Octave · drafted with AI, reviewed by Octave · Last updated 5 November 2026

