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:

  1. A user column. Files in /etc/cron.d/ need a user (root above) between the schedule and the command. A line without it is skipped.
  2. The file name has no dot. Debian cron ignores /etc/cron.d/ files whose names contain a dot. shop-jobs runs; shop-jobs.cron never does.
  3. 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.
  4. Permissions 0644, owned by root. Cron rejects files that the group or others can write (for example 0664).
  5. Escape every % as \%. In a crontab, % means "newline, and send the rest to stdin". date +%F silently breaks the whole command.

Two more habits that save hours:

  • Use absolute paths. Cron's default PATH is just /usr/bin:/bin, so python from /usr/local/bin is not found. The run-job wrapper in Step 3 fixes this, but absolute paths make the crontab readable on its own.
  • flock -n stops 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: true puts a tiny init process in front of cron. Without it, cron is process 1, and process 1 ignores SIGTERM because cron sets no handler for it. Every docker stop then waits the full grace period and ends with a SIGKILL.
  • TZ sets the time zone for the schedule. Cron reads it at startup, so 30 2 * * * means 02:30 Kigali time here. The image needs tzdata, which the Dockerfile installs. Leave TZ out and schedules run in UTC.
  • stop_grace_period: 30s caps 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 ports on 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/root with 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 cron container on the Dedicated tier with 24×7 on and Auto Sleep off.
  • Run exactly one copy. Don't add replicas to the cron container: each replica runs the full schedule, so every job fires once per copy. Scale web instead.

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

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.

Try it on Hubfly space

By Octave · drafted with AI, reviewed by Octave · Last updated 5 November 2026