# Cron in Docker Containers: Run Scheduled Jobs Correctly

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/cron-in-docker

> How to run cron in a Docker container: PID 1 and signals, job output in docker logs, environment variables, supercronic, health checks, and job monitoring.

**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](https://docs.docker.com/engine/install/linux-postinstall/) 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:

```text
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>&1
```

`docker 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](https://docs.docker.com/reference/cli/docker/container/exec/)).

Kubernetes CronJobs and ECS scheduled tasks, which EventBridge Scheduler starts through `RunTask` ([ECS guide](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/tasks-scheduled-eventbridge-scheduler.html)), 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](https://cronradar.com/blog/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](https://docs.docker.com/reference/cli/docker/service/create/#running-as-a-job)), 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](https://cronradar.com/blog/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](https://sources.debian.org/src/cron/3.0pl1-197/cron.c/#L79-L84), [do_command.c](https://sources.debian.org/src/cron/3.0pl1-197/do_command.c/#L520-L527), [misc.c](https://sources.debian.org/src/cron/3.0pl1-197/misc.c/#L280-L305), and [job.c](https://sources.debian.org/src/cron/3.0pl1-197/job.c/#L36-L55); BusyBox's [crond.c](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L1043-L1055) and [change_identity.c](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/libbb/change_identity.c#L33-L54); supercronic's [README](https://github.com/aptible/supercronic/blob/v0.2.51/README.md), [main.go](https://github.com/aptible/supercronic/blob/v0.2.51/main.go#L116-L128), and [cron.go](https://github.com/aptible/supercronic/blob/v0.2.51/cron/cron.go#L165-L210).

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](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/cron.c#L241-L247)). Its foreground flag is `crond -n`, and its [cron(8)](https://man7.org/linux/man-pages/man8/cron.8.html) 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](https://docs.docker.com/reference/cli/docker/container/run/#attach)). 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`](https://docs.docker.com/reference/cli/docker/container/run/#stop-timeout)).

PID 1 also inherits every orphaned process ([pid_namespaces(7)](https://man7.org/linux/man-pages/man7/pid_namespaces.7.html)), such as a child a job started in the background. Debian's cron reaps any child that exits ([cron.c](https://sources.debian.org/src/cron/3.0pl1-197/cron.c/#L408-L440)); BusyBox crond waits only for the jobs it started ([crond.c](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L992)), 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`](https://docs.docker.com/reference/cli/docker/container/run/#init), [Compose](https://docs.docker.com/reference/compose-file/services/#init)). 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](https://docs.docker.com/reference/dockerfile/#entrypoint)).

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)](https://man7.org/linux/man-pages/man7/pid_namespaces.7.html)).

### 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](https://docs.docker.com/engine/logging/)). Debian's cron writes each job's output to a temporary file and mails it ([do_command.c](https://sources.debian.org/src/cron/3.0pl1-197/do_command.c/#L181-L184)). 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](https://sources.debian.org/src/cron/3.0pl1-197/misc.c/#L563-L571)) 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)](https://man7.org/linux/man-pages/man5/proc_pid_fd.5.html)), 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](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L757-L784)), 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](https://cronradar.com/blog/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](https://cronradar.com/blog/cron-timezone) 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, so `app.cron` is ignored ([cron(8)](https://manpages.debian.org/trixie/cron/cron.8.en.html)). A missing final newline discards the whole file ([user.c](https://sources.debian.org/src/cron/3.0pl1-197/user.c/#L264-L276)). `COPY` keeps the file's mode from your build context ([Dockerfile reference](https://docs.docker.com/reference/dockerfile/#copy)), so set `--chmod=0644`.
- **Debian, user crontabs:** files in `/var/spool/cron/crontabs` must be mode 0600 ([database.c](https://sources.debian.org/src/cron/3.0pl1-197/database.c/#L348-L351)); install them with `crontab` in a `RUN` step rather than copying them.
- **BusyBox:** the file must be named after an existing user and owned by root ([crond.c](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L428-L440)). `COPY --chown=app` makes crond skip it.
- **Supercronic:** a line it can't parse stops it at startup ([main.go](https://github.com/aptible/supercronic/blob/v0.2.51/main.go#L233-L245)), which is loud. Run `supercronic -test` during 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](https://sources.debian.org/src/cron/3.0pl1-197/misc.c/#L280-L305), [L175-L188](https://sources.debian.org/src/cron/3.0pl1-197/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:

```text
# /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.sh
```

The entrypoint saves the container's variables, then becomes cron. `export -p` writes them quoted so that the shell can read them back ([POSIX](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html#export)):

```bash
#!/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 -f
```

The launcher loads those variables, opens the container's stdout while still root, and then runs the job as `app` with [setpriv](https://man7.org/linux/man-pages/man1/setpriv.1.html), part of util-linux and already in `debian:trixie-slim`:

```bash
#!/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>&1
```

```dockerfile
FROM 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"]
```

```bash
docker build -t app-cron .
docker run -d --name app-cron --init --restart unless-stopped \
  -e CRONRADAR_API_KEY -e DATABASE_URL app-cron
```

`docker 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:

```text
# /etc/crontabs/app
30 2 * * * /usr/local/bin/backup-monitored.sh
```

```dockerfile
FROM 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:

```text
# /etc/supercronic/crontab
30 2 * * * /usr/local/bin/backup-monitored.sh
```

The install stanza is the one published with the [v0.2.51 release](https://github.com/aptible/supercronic/releases/tag/v0.2.51), released October 21, 2026, which verifies the download's SHA-1 checksum before installing it:

```dockerfile
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:

```yaml
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_key
```

## Health 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](https://docs.docker.com/engine/containers/start-containers-automatically/)). Prefer that layout. A cron started in the background beside another program can die while the container keeps running. A health check notices:

```dockerfile
HEALTHCHECK --interval=1m CMD kill -0 "$(cat /var/run/crond.pid)" || exit 1
```

On 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](https://github.com/moby/moby/blob/docker-v29.9.2/daemon/health.go#L228-L251)). Swarm does shut unhealthy task containers down so the service replaces them ([controller.go](https://github.com/moby/moby/blob/docker-v29.9.2/daemon/cluster/executor/container/controller.go#L280-L286)). 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](https://cronradar.com/blog/what-is-a-cron-job)), and supercronic computes its first run from the moment it starts ([cron.go](https://github.com/aptible/supercronic/blob/v0.2.51/cron/cron.go#L165-L171)). 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](https://cronradar.com/blog/cron-dead-mans-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:

```bash
#!/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_KEY` reaches the entrypoint, which saves it for `cron-run`.
- **BusyBox crond and supercronic:** `-e` or Compose `environment` reaches the job directly.
- **Any of them:** a Compose secret, mounted at `/run/secrets/cronradar_api_key` ([Compose secrets](https://docs.docker.com/compose/how-tos/use-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](https://docs.docker.com/reference/compose-file/services/#secrets)), 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`.
