Cron in Docker Containers: Run Scheduled Jobs Correctly
Contents
To run cron in a Docker container, start the scheduler in the foreground as the container's main process, send each job's output to the container's stdout, and give jobs the variables they need explicitly, because most cron daemons drop the container's environment. Supercronic, a cron replacement built for containers, does all three by default. Often the simpler answer is no daemon in the container at all: a host crontab that runs docker run, or your orchestrator's scheduler. This guide covers Debian's cron 3.0pl1-197 on debian:trixie-slim, BusyBox crond 1.37.0 on alpine:3.24, supercronic 0.2.51, and Docker Engine 29, checked October 24, 2026.
Choose Where the Schedule Lives#
| Option | Fits when | Watch for |
|---|---|---|
Host crontab running docker run --rm or docker exec |
One host you control | The host's cron rules still apply, and the docker group grants root-level privileges to the job's account |
| A scheduler container running cron or supercronic | Compose on one host; jobs need the app's image | Run exactly one replica, or every job runs once per replica |
| An in-process scheduler in a long-running app | The app is always on anyway | Each replica runs its own copy of the schedule unless the library coordinates them |
| The orchestrator's scheduler | Kubernetes or Amazon ECS | Docker Swarm has no scheduler |
A host entry keeps cron where it was designed to run and gives each run a fresh container from the app's image:
30 2 * * * /usr/bin/docker run --rm --env-file /etc/app/backup.env registry.example.com/app:1.4.2 /usr/local/bin/backup-monitored.sh >> /var/log/app/backup.log 2>&1docker exec app /usr/local/bin/backup-monitored.sh instead runs the job inside a running container, with the environment it was created with, and only while its main process is running (docker exec).
Kubernetes CronJobs and ECS scheduled tasks, which EventBridge Scheduler starts through RunTask (ECS guide), start a new container for each run. The job is that container's main process and its output is the container's log, so none of the daemon problems below apply; Kubernetes CronJob monitoring covers that path. Swarm's replicated-job and global-job modes run a task to completion once, with no schedule (docker service create), so on Swarm you need a scheduler container or an outside trigger.
Why Cron Misbehaves Inside a Container#
A cron daemon assumes a server: an init system above it, syslog and a mail server beside it, and other accounts whose environment its jobs shouldn't inherit. A container usually has none of these. Once the daemon is running, the usual crontab mistakes in cron job not running apply too.
| Debian cron 3.0pl1-197 | BusyBox crond 1.37.0 | supercronic 0.2.51 | |
|---|---|---|---|
| Stay in the foreground | cron -f |
crond -f |
Always |
| The daemon's own messages | syslog only | stderr with -d 8 |
stderr |
| Job output | Mailed, or discarded with no MTA | crond's own stdout and stderr | Logged line by line |
ENV and docker run -e in jobs |
No | Yes | Yes |
| SIGTERM while PID 1 | Ignored | Ignored | Waits for running jobs, then exits |
| Reaps orphaned processes as PID 1 | Yes | No | Yes |
| Daemon running as non-root | Exits at startup | Every job fails switching user | Yes; jobs run as the same user |
| Previous run still going when due | Starts another copy | Skips the run | Skips the run and warns |
Sources: Debian's cron.c, do_command.c, misc.c, and job.c; BusyBox's crond.c and change_identity.c; supercronic's README, main.go, and cron.go.
cronie, the cron in Fedora- and RHEL-based images, keeps the container's environment from jobs like Debian's does, and mails their output or, with no sendmail installed, sends it to syslog. Unlike Debian's, it handles SIGTERM (cron.c). Its foreground flag is crond -n, and its cron(8) says that mode needs pam_loginuid.so removed from /etc/pam.d/crond.
PID 1 Ignores SIGTERM#
"A process running as PID 1 inside a container is treated specially by Linux: it ignores any signal with the default action" (docker run). Debian's cron installs handlers only for SIGCHLD and SIGHUP, and BusyBox crond installs none, so as PID 1 both ignore the SIGTERM from docker stop. Docker waits 10 seconds and sends SIGKILL (--stop-timeout).
PID 1 also inherits every orphaned process (pid_namespaces(7)), such as a child a job started in the background. Debian's cron reaps any child that exits (cron.c); BusyBox crond waits only for the jobs it started (crond.c), so orphans stay as zombies.
Start these containers with docker run --init, or init: true in Compose, which runs Docker's bundled tini as PID 1 to forward signals and reap processes (--init, Compose). Write CMD and ENTRYPOINT in exec form, and end entrypoint scripts with exec: in shell form the program "will not be the container's PID 1, and will not receive Unix signals" (Dockerfile reference).
Even then, stopping a Debian or BusyBox container kills running jobs: when PID 1 exits, the kernel sends SIGKILL to every process in the container (pid_namespaces(7)).
The Daemon Backgrounds Itself#
Without its foreground flag, cron forks and the original process exits. As the main process, that ends the container. The common workaround, cron && tail -f /dev/null, keeps the container up but leaves tail as PID 1, so nothing notices when cron dies. Use cron -f, crond -f, or crond -n instead.
Job Output Never Reaches docker logs#
docker logs shows the main process's stdout and stderr (Docker logging). Debian's cron writes each job's output to a temporary file and mails it (do_command.c). With no mail server it drops the output and logs No MTA installed, discarding output to syslog, which is the only place its messages go (misc.c) and which a container rarely runs.
The usual fix is to append >> /proc/1/fd/1 2>&1 to the crontab line, which opens the main process's stdout. /dev/stdout doesn't work, because inside a Debian cron job it is cron's temporary file. Opening /proc/1/fd/1 passes a ptrace access check (proc_pid_fd(5)), so a job running as a different user from PID 1 gets Permission denied. The Debian image below opens it as root before dropping to the job's user.
BusyBox crond leaves jobs on its own stdout and stderr when the crontab sets no MAILTO (crond.c), so output arrives with no redirect. Supercronic logs each output line with the job's schedule and command.
The Container's Environment Stops at the Daemon#
Debian's cron and cronie build a fresh environment for each job, so ENV lines and docker run -e values reach the daemon but not its jobs. BusyBox crond and supercronic pass their environment through. Cron environment variables has each implementation's rules. The Debian image below saves the variables at startup and loads them in each job.
The time zone follows the same split: a TZ variable reaches BusyBox and supercronic jobs, but not Debian or cronie jobs. A container runs in UTC unless the image sets a zone, and alpine:3.24 lacks tzdata; cron time zones covers setting one so that both the daemon and its jobs see it.
Crontab Files That Are Silently Ignored#
Each daemon skips a file it doesn't trust. Debian's cron reports it only to syslog, and BusyBox crond doesn't report it at all:
- Debian,
/etc/cron.d: the file must be owned by root, not group- or other-writable, and named with only letters, digits, underscores, and hyphens, soapp.cronis ignored (cron(8)). A missing final newline discards the whole file (user.c).COPYkeeps the file's mode from your build context (Dockerfile reference), so set--chmod=0644. - Debian, user crontabs: files in
/var/spool/cron/crontabsmust be mode 0600 (database.c); install them withcrontabin aRUNstep rather than copying them. - BusyBox: the file must be named after an existing user and owned by root (crond.c).
COPY --chown=appmakes crond skip it. - Supercronic: a line it can't parse stops it at startup (main.go), which is loud. Run
supercronic -testduring the build to catch it earlier.
Running as Non-Root#
Debian's cron must start as root: it locks /var/run/crond.pid and calls seteuid(0), exiting if either fails (misc.c, L175-L188). BusyBox crond calls initgroups before every job, which fails without root. Both run jobs as other accounts when the daemon is root: through the user field in /etc/cron.d, or a file at /etc/crontabs/<user>. Supercronic never switches users; set USER in the Dockerfile and its jobs run as that account.
Three Working Images#
Each image runs /usr/local/bin/backup.sh at 02:30 UTC through backup-monitored.sh, the reporting wrapper in the monitoring section below. Use backup.sh directly if you don't monitor it. Each job runs as an unprivileged app account.
Debian With cron#
The crontab, copied to /etc/cron.d/app, runs a launcher as root:
# /etc/cron.d/app: minute hour day-of-month month day-of-week user command
30 2 * * * root /usr/local/bin/cron-run /usr/local/bin/backup-monitored.shThe entrypoint saves the container's variables, then becomes cron. export -p writes them quoted so that the shell can read them back (POSIX):
#!/bin/sh
# /usr/local/bin/entrypoint.sh
set -eu
# Debian's cron gives each job a fresh environment, so save the container's
# variables for cron-run. Root-only, because they include secrets.
umask 077
export -p > /run/cron.env
exec cron -fThe launcher loads those variables, opens the container's stdout while still root, and then runs the job as app with setpriv, part of util-linux and already in debian:trixie-slim:
#!/bin/sh
# /usr/local/bin/cron-run: run a cron job as app, with the container's
# environment, writing its output to docker logs.
. /run/cron.env
export HOME=/home/app USER=app LOGNAME=app
exec setpriv --reuid=app --regid=app --init-groups "$@" >> /proc/1/fd/1 2>&1FROM debian:trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends cron curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --shell /usr/sbin/nologin app
COPY --chmod=0644 crontab /etc/cron.d/app
COPY --chmod=0755 entrypoint.sh cron-run backup.sh backup-monitored.sh /usr/local/bin/
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]docker build -t app-cron .
docker run -d --name app-cron --init --restart unless-stopped \
-e CRONRADAR_API_KEY -e DATABASE_URL app-crondocker logs app-cron shows job output only; cron's own messages still go to syslog.
Alpine With BusyBox crond#
BusyBox needs no launcher. The crontab's file name sets the user its jobs run as:
# /etc/crontabs/app
30 2 * * * /usr/local/bin/backup-monitored.shFROM alpine:3.24
RUN apk add --no-cache curl \
&& adduser -D app
COPY --chmod=0755 backup.sh backup-monitored.sh /usr/local/bin/
# Owned by root; the file name is the account its jobs run as.
COPY --chmod=0600 crontab /etc/crontabs/app
CMD ["crond", "-f", "-d", "8"]Run it with --init as well. -d 8 puts crond's own lines, such as each job start, on stderr, and variables passed with -e reach the jobs.
Supercronic#
Supercronic's crontab has no user field:
# /etc/supercronic/crontab
30 2 * * * /usr/local/bin/backup-monitored.shThe install stanza is the one published with the v0.2.51 release, released October 21, 2026, which verifies the download's SHA-1 checksum before installing it:
FROM debian:trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --shell /usr/sbin/nologin app
# Latest releases available at https://github.com/aptible/supercronic/releases
ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.2.51/supercronic-linux-amd64 \
SUPERCRONIC_SHA1SUM=1692066e350a88ff4d7d6474fe3c7a6c41ec5e89 \
SUPERCRONIC=supercronic-linux-amd64
RUN curl -fsSLO "$SUPERCRONIC_URL" \
&& echo "${SUPERCRONIC_SHA1SUM} ${SUPERCRONIC}" | sha1sum -c - \
&& chmod +x "$SUPERCRONIC" \
&& mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" \
&& ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic
COPY --chmod=0755 backup.sh backup-monitored.sh /usr/local/bin/
COPY --chmod=0644 crontab /etc/supercronic/crontab
RUN supercronic -test /etc/supercronic/crontab
USER app
CMD ["supercronic", "/etc/supercronic/crontab"]For arm64, use supercronic-linux-arm64 with SHA-1 9d4c2000493215a1898502b2fc2dada3e8ea8c7a. Leave out --init: as PID 1, supercronic reaps processes itself. On SIGTERM it stops starting jobs and waits for running ones, so give it a stop grace period longer than your longest job:
services:
scheduler:
build: .
restart: unless-stopped
stop_grace_period: 30m
environment:
DATABASE_URL: postgres://app@db:5432/app
secrets:
- cronradar_api_key
secrets:
cronradar_api_key:
file: ./secrets/cronradar_api_keyHealth Checks and Restarts#
With the daemon as the main process, or tini's only child, a crash ends the container and a restart policy such as unless-stopped starts it again; restart policies act when a container exits (restart policies). Prefer that layout. A cron started in the background beside another program can die while the container keeps running. A health check notices:
HEALTHCHECK --interval=1m CMD kill -0 "$(cat /var/run/crond.pid)" || exit 1On Alpine, the file is /run/crond.pid. An unhealthy status is only a status: Docker Engine records it and emits an event without restarting a standalone container (health.go). Swarm does shut unhealthy task containers down so the service replaces them (controller.go). Neither tells you whether a job ran or succeeded.
Restarts also cost runs. Cron daemons start at the current minute and don't catch up (what cron won't do), and supercronic computes its first run from the moment it starts (cron.go). A deploy that stops the scheduler at 02:29 and starts it at 02:31 skips that night's backup, and a job running at the stop is killed. Deploy outside job windows, or make the job safe to rerun and start it by hand afterward: docker exec app-cron /usr/local/bin/cron-run /usr/local/bin/backup-monitored.sh.
Monitor Each Job With a Check-In#
None of this tells you a job didn't run: a skipped run, an ignored crontab, and a stopped container produce no error. A check-in that must arrive on schedule does, the dead man's switch pattern. There is no CronRadar SDK or auto-discovery for cron in containers, so each job reports with its own HTTP check-in. This wrapper reports the start, runs the backup, reports complete or fail, and exits with the backup's own code. Each report gives up after five seconds and ignores errors:
#!/bin/sh
# /usr/local/bin/backup-monitored.sh
url="https://cron.life/api/v1/ping/backup"
run_id="backup-$(date +%s)-$$"
# Use the environment if it has the key, otherwise a Docker secret file.
CRONRADAR_API_KEY="${CRONRADAR_API_KEY:-$(cat /run/secrets/cronradar_api_key 2>/dev/null)}"
report() {
event=$1
shift
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --get --user "$CRONRADAR_API_KEY:" \
--data-urlencode "schedule=30 2 * * *" \
--data-urlencode "gracePeriod=900" \
--data-urlencode "run_id=$run_id" \
"$@" "$url/$event" || true
}
report start
/usr/local/bin/backup.sh
rc=$?
if [ "$rc" -eq 0 ]; then
report complete
else
report fail --data-urlencode "message=backup.sh exited with code $rc"
fi
exit "$rc"Pass the API key as a container secret or variable, never in the image:
- Debian's cron:
docker run -e CRONRADAR_API_KEYreaches the entrypoint, which saves it forcron-run. - BusyBox crond and supercronic:
-eor Composeenvironmentreaches the job directly. - Any of them: a Compose secret, mounted at
/run/secrets/cronradar_api_key(Compose secrets), which the wrapper reads when the variable is empty. A file-based secret is a bind mount that keeps the host file's owner and mode (Compose reference), so the job's account must be able to read it.
Every check-in carries the schedule, so the first run creates the monitor and a deleted one comes back on the next run. A fail alerts immediately. If the container is down at 02:30, no check-in arrives: the monitor shows warning, then goes critical once the 15-minute grace period passes. A run killed mid-way leaves a start with no finish; CronRadar flags the job as stuck only if nothing else arrives by its next expected time plus grace, because the next run's start resets that window. CronRadar evaluates schedules in UTC, which matches a container that doesn't set a zone.
Frequently Asked Questions#
How do I run a cron job inside a Docker container?#
Install a cron daemon or supercronic in the image, copy in the crontab, and make the scheduler the container's command in the foreground, such as CMD ["crond", "-f", "-d", "8"] on Alpine. Start Debian's cron or BusyBox crond with --init so docker stop works. If the host already runs cron, an entry that calls docker run --rm is often simpler.
Why is cron not running in my Docker container?#
If the container exits right after starting, cron put itself in the background; use cron -f or crond -f. If it keeps running but nothing happens, cron may be ignoring the crontab, which Debian's cron reports only to syslog, or the job may be failing without the container's variables while its error output is discarded. Check the crontab's owner, mode, name, and final newline, and send job output to docker logs.
How do I see cron job output in docker logs?#
BusyBox crond and supercronic put it there by default. With Debian's cron or cronie, redirect the job to /proc/1/fd/1. That works only for a job running as the same user as the container's main process, so open it as root and drop privileges, as cron-run does.
Should I use cron or supercronic in Docker?#
Use supercronic when you build the image: it passes the environment, logs to stderr, handles SIGTERM, reaps processes as PID 1, and runs without root. Keep a distribution's cron when jobs in one container must run as different users, which supercronic doesn't do.
Do environment variables reach cron jobs in Docker?#
Under BusyBox crond and supercronic, yes: jobs inherit ENV and docker run -e values. Under Debian's cron and cronie, no. Save the variables when the container starts and load them in the job, or read secrets from files under /run/secrets.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.