Debian cron vs cronie vs BusyBox crond: CRON_TZ, /etc/cron.d, env vars and syntax compared
How cron differs on Ubuntu, Debian, Fedora, RHEL, Arch and Alpine: which crons support CRON_TZ, read /etc/cron.d, pass Docker env vars, handle a bad crontab line, and accept seconds, L, W and #. Tested on six distros.
14 min read
You add CRON_TZ=Europe/Berlin to a crontab, because the report has to land at 08:00 Berlin time
and the server runs in UTC. On the Fedora box where you tested it, it works. On the Ubuntu box where
it runs for real, the job fires two hours late, and nothing anywhere says the line was ignored.
That’s because “cron” isn’t one program. Debian and Ubuntu ship one, Fedora, RHEL and Arch another,
Alpine a third, and Docker images often use a fourth. They agree on five fields and */5 and
disagree on nearly everything else. We ran the same crontabs through the cron each distro ships,
inside containers, and read the source of the ones we didn’t run.
Short answer
Debian, Ubuntu and Raspberry Pi OS run Debian’s cron (a patched Vixie cron 3.0). Fedora, RHEL,
AlmaLinux, Rocky and Arch run cronie. Alpine runs BusyBox crond. CRON_TZ, RANDOM_DELAY and ~
random fields only work on cronie. One bad line disables a whole /etc/cron.d file on Debian and
Ubuntu, and so does a file name with a dot in it. Alpine doesn’t read /etc/cron.d at all. Only
BusyBox crond and supercronic pass the container’s environment to jobs. None of the distro crons
supports a seconds field, L, W or #, or logs a job’s exit code by default. If a crontab has
to run on more than one of them, stick to five plain fields and set time zone, env and locking
inside the command. If you’d rather have one scheduler that behaves the same everywhere,
supercronic does that for containers, and RunWisp, an open-source cron replacement that ships as a
single binary, does it for servers too (below).
Which cron do you have?
| Run this | If it prints | You have |
|---|---|---|
dpkg -S "$(readlink -f "$(command -v cron)")" | cron: /usr/sbin/cron | Debian’s cron (Debian, Ubuntu, Pi OS) |
crond -V | cronie 1.7.2 | cronie (Fedora, RHEL, Alma, Arch) |
crond --help 2>&1 | head -1 | BusyBox v1.37.0 (…) | BusyBox crond (Alpine) |
crond -h 2>&1 | head -1 | dillon's cron daemon 4.6 | dcron (Alpine’s dcron package) |
supercronic -version | a version number | supercronic |
Debian’s cron has no -V and prints its usage instead. The versions we tested: Debian 13 ships
3.0pl1-197, Ubuntu 26.04 3.0pl1-200ubuntu1, Fedora 44 and Arch cronie 1.7.2, AlmaLinux 10
cronie 1.7.0, Alpine 3.24 BusyBox 1.37.0. The supercronic facts below come from v0.2.50’s source.
The matrix
✓ means yes, ✗ means no, and the rows explain the exceptions. The first three columns are what
the distro installs when you apt/dnf/pacman/apk add cron.
| Debian, Ubuntu | cronie | BusyBox (Alpine) | supercronic | RunWisp (ours) | |
|---|---|---|---|---|---|
CRON_TZ= changes when jobs run | ✗ | ✓ | ✗ | ✓ one zone | ✓ per task |
Reads /etc/cron.d | ✓ | ✓ | ✗ | ✗ | ✓ |
A file named backup.cron runs | ✗ | ✓ | ✗ | ✓ | ✓ |
| A bad line costs only that line | ✗ whole file | ✓ | ✗ runs it mangled | ✗ won’t start | ✓ reported |
Any NAME=value line reaches the job | ✓ | ✓ | ✗ | ✓ | ✓ |
| Container env vars reach the job | ✗ | ✗ | ✓ | ✓ | ✓ |
@reboot | ✓ | ✓ | ✓ | ✗ exits | ✓ |
| Seconds field | ✗ | ✗ | ✗ | ✓ 7 fields | ✓ 6 fields |
L, W, # | ✗ | ✗ | ✗ | ✓ | ✓ |
| Random start delay | ✗ | ✓ | ✗ | ✗ | ✓ |
| Won’t start a job that’s still running | ✗ | ✗ | ✓ | ✓ | ✓ |
| Keeps output without a mail server | ✗ | ✓ | only with -f | ✓ | ✓ |
| Records the exit code | with -L 15 | ✗ | ✗ | ✓ | ✓ |
Each row has a story, and the next sections tell the ones that cost people a morning.
Time zones: CRON_TZ and TZ
We put three jobs in one crontab, each due two minutes from now: one written in UTC, one after a
TZ=Asia/Kolkata line, one after CRON_TZ=Asia/Kolkata, both with the Kolkata time. The
containers ran in UTC. India is UTC+5:30, so a job that ignores the zone line misses by five and a
half hours:
24 10 * * * echo utc >> /tmp/hits
TZ=Asia/Kolkata
54 15 * * * echo tz >> /tmp/hits
CRON_TZ=Asia/Kolkata
54 15 * * * echo crontz >> /tmp/hits
| Cron | utc | tz | crontz |
|---|---|---|---|
| Debian 13, Ubuntu 26.04 | ✓ | ✗ | ✗ |
| Fedora 44, Alma 10, Arch | ✓ | ✗ | ✓ |
| Alpine 3.24 (BusyBox) | ✓ | ✗ | ✗ |
TZ= never moved a job on any of them. It only lands in the job’s environment, so date inside
the job prints Kolkata time while the job runs on UTC’s clock. Debian’s crontab(5) says so in
its LIMITATIONS section, and suggests checking the hour inside the command instead. The string
CRON_TZ appears nowhere in Debian’s cron, not even in the man pages. BusyBox goes further and
drops both lines: it only understands MAILTO, SHELL and PATH, and anything else with an =
is discarded.
cronie applies CRON_TZ to every line after it, and its own source warns that such jobs may be
skipped or run twice when that zone changes its clocks. supercronic takes CRON_TZ too, but only
one zone per crontab: the last one wins.
The portable fix is to keep the server in UTC and convert by hand, or to guard inside the command:
# 08:00 Berlin, in summer and winter: run at both UTC hours, keep the right one
0 6,7 * * * [ "$(TZ=Europe/Berlin date +\%H)" = 08 ] && /usr/local/bin/report
The \% is for Debian’s cron and cronie, which turn a bare % into a newline. BusyBox hands the
line to the shell as is, and the shell drops the backslash, so this line works on all three. Inside
single quotes it wouldn’t: '\%' stays a backslash and a percent sign. (More % trouble in
the jitter post.) The clock change itself
treats each cron differently too; this post covers it.
One bad line
crontab checks a file before installing it, on Debian and cronie alike:
$ crontab /tmp/ct
"/tmp/ct":1: bad minute
errors in crontab file, can't install.
Files dropped into /etc/cron.d by config management, a package or a COPY in a Dockerfile skip
that check. We wrote this file with a bad middle line:
* * * * * root echo good1 >> /tmp/hits
61 * * * * root echo bad >> /tmp/hits
* * * * * root echo good2 >> /tmp/hits
| Cron | What ran after two minutes |
|---|---|
| Debian 13, Ubuntu 26.04 | nothing |
| Fedora 44 (cronie) | good1 and good2, twice each |
Debian’s cron ignores the whole file when any line fails to parse, and so does a file with no
newline after the last line. It logs “this crontab file will be ignored” to syslog, which in a
container with no syslog daemon goes nowhere. Commands longer than 999 characters trip it too.
On Ubuntu 24.04, so does an unquoted empty variable like MAILTO=; Debian 13 and Ubuntu 26.04
accept it. cronie drops only the line it can’t read.
BusyBox is the odd one. Its crontab installed the same bad line without complaint, and crond then
ran 61 * * * * every minute. When BusyBox fails to parse a field it logs a parse error and keeps
the line with whatever it had read so far. Reading the parser, 5#2 in the day-of-week field
becomes every Friday, 15W becomes the 15th, and day-of-week 7 becomes every day.
File names and the places cron looks
Debian’s cron skips any file in /etc/cron.d whose name has anything but letters, digits, _ and
-. backup.cron, app.conf and certbot.disabled are all skipped, with no log line at all. It
looks deliberate: foo.dpkg-old shouldn’t run after an upgrade. It also means
COPY crontab.txt /etc/cron.d/ in a Dockerfile does nothing. In our test, Debian and Ubuntu ran
/etc/cron.d/plain and skipped /etc/cron.d/backup.cron. Fedora ran both.
cronie has its own rules: it skips names starting with . or # and ending in ~, .rpmsave,
.rpmorig or .rpmnew, and rejects any file with an execute bit (“BAD FILE MODE”). Debian runs
executable files fine, so a file that works there can fail on Fedora for being chmod +x.
BusyBox has no /etc/cron.d and no system crontab with a user column. Alpine’s crond reads
/etc/crontabs/<user>, where the file must be owned by root, named after a real user, and named
without a dot. A crontab bind-mounted from the host, owned by uid 1000, is ignored. supercronic reads the one file you pass it.
Both Debian’s cron and cronie notice an edited /etc/cron.d file within a minute. Debian checks
user crontabs only when the spool directory changes, so edit those with crontab -e, not an
editor. BusyBox rereads its crontab directory when its modification time changes, and rescans
everything once an hour.
Environment variables in Docker
We started each container with -e FOO=bar and cron as PID 1, the usual Docker setup, and had a
job print what it got:
Debian 13 FOO= PATH=/usr/bin:/bin
Ubuntu 26.04 FOO= PATH=/usr/bin:/bin
Fedora 44 FOO= PATH=/usr/bin:/bin:/usr/sbin:/sbin
Alpine 3.24 FOO=bar PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Debian’s cron and cronie build each job’s environment from nothing: HOME, SHELL=/bin/sh,
LOGNAME, a default PATH, the crontab’s own NAME=value lines, and whatever PAM’s pam_env
adds from /etc/environment. Whatever you passed to the container stops at the cron process.
cronie lets the locale variables (LANG, LC_*) through, and little else.
PATH is its own trap. Under systemd, Ubuntu starts cron with -P and its /etc/environment sets
a full PATH including /usr/local/bin and /snap/bin; Debian doesn’t, and jobs get
/usr/bin:/bin. So on the same Ubuntu image a job finds /usr/local/bin/tool on a VM and not in
a container, where you started cron -f without -P.
BusyBox and supercronic pass the daemon’s environment through. On BusyBox that’s the only way in:
NAME=value lines other than MAILTO and SHELL are dropped, and Alpine’s build ignores the
crontab’s PATH= too.
On Debian-based images, the usual workaround is dumping the environment at startup and sourcing it:
#!/bin/sh
# entrypoint: hand the container's env to cron jobs
export -p > /etc/container.env
chmod 600 /etc/container.env
exec cron -f
*/5 * * * * root . /etc/container.env; /usr/local/bin/sync
export -p writes every variable, secrets included, hence the chmod.
Syntax past five fields
None of the three distro crons parses a seconds field, L (last), W (nearest weekday) or #
(nth weekday). Debian’s cron and cronie reject them at install. BusyBox accepts and misreads them,
as above.
supercronic does all of them, with a catch. Five fields mean the usual thing. Seven fields are
seconds, the usual five, then a year. Six fields are the usual five plus a year, so
*/30 * * * * *, which means every 30 seconds in Quartz, Spring and RunWisp, means every 30
minutes in supercronic.
Random spread is cronie-only among the distro crons, in two forms: RANDOM_DELAY=30 delays jobs by
a random 0 to 30 minutes, and 0~59 picks a random minute in that range. Both are fixed when cron
loads: RANDOM_DELAY scales by one random factor drawn when the daemon starts, shared by every job,
and ~ picks again on every reload. OpenBSD’s cron also has ~. More on spreading jobs out in
the jitter post.
Output, mail and exit codes
Every cron here except supercronic was built to mail you the output. What happens without a mail server, which is most servers and every container:
- Debian, Ubuntu: output is discarded, and syslog gets one line: “No MTA installed, discarding output”. Ubuntu demoted the mail server from Recommends to Suggests, so that’s the default there.
- cronie: if
/usr/sbin/sendmailisn’t there at startup, it switches itself to logging output to syslog, oneCMDOUTline per output line. - BusyBox: without a
MAILTO=line, output goes to crond’s own stdout. Runcrond -fand it shows up indocker logs; let crond go into the background and it’s gone. - supercronic: logs every output line to stderr, which is
docker logs.
None of the distro crons records a job’s exit code by default. Debian’s cron logs failures if you
set EXTRA_OPTS="-L 15" in /etc/default/cron; cronie and BusyBox never do. supercronic
logs error running command: exit status N. Where to find what cron does
log and how to get alerted on a failure cover
the workarounds.
@reboot and running twice
@reboot works on all three distro crons, once per boot, through a marker file in /run, which is
why it doesn’t run when your container restarts. supercronic doesn’t support
it and refuses to start if a crontab has one, and the same for @midnight. OpenBSD’s cron runs
@reboot every time cron starts.
Overlap is where BusyBox wins. It won’t start a line that’s still running from last time, and logs “process already running” instead. supercronic skips the same way by default. Debian’s cron and cronie start the next run regardless; flock fixes that.
The others: OpenBSD cron, dcron, fcron
You’ll meet these less often, but each has a trap for anyone coming from Vixie cron:
- OpenBSD cron has per-line flags:
-nmails only on failure,-qskips the log line,-sruns one instance at a time.~random fields arrived in OpenBSD 6.7. No/etc/cron.d. - dcron (Alpine’s
dcronpackage) treats@dailyas “24 hours since the last run”, not midnight, and drops the line unless it has anID=. When both day-of-month and day-of-week are set, it means “the Nth such weekday”, not either one. - fcron uses
~to exclude values (5-8~6~7is 5 and 8), ANDs day-of-month and day-of-week by default, and has a real per-jobtimezone(...)and catch-up after downtime (bootrun).
A crontab that works on all of them
If the same file has to run on Debian, Fedora and Alpine:
- Five fields, numbers,
*,,,-and/only. No seconds,L,W,#,~or day names in ranges. - Assume the server’s time zone. No
CRON_TZ. Keep servers in UTC. - Set env inside the command:
cd /srv/app && FOO=bar ./job, or source a file. - Use absolute paths for everything;
PATHis different on all three. - Lock and time out inside the command:
flock -n /run/lock/job.lock timeout 20m ./job. - Redirect output yourself:
>> /var/log/job.log 2>&1, since nobody else keeps it. - Name files in
/etc/cron.dwith letters, digits,-and_, mode 644, owned by root, ending in a newline. On Alpine, use/etc/crontabs/rootinstead.
That’s most of cron’s features gone, which is the point: the portable subset is small.
Or run the same scheduler everywhere
If you run containers, supercronic is the simplest swap: one static binary, one crontab file,
env vars passed through, output and exit codes in docker logs, no overlap by default. It has no
@reboot, one time zone per file, reloads only on SIGUSR2 or with -inotify, and that
six-field gotcha.
RunWisp (our project, so weigh accordingly) is an open-source cron replacement that ships as a
single binary, and it runs the same on Debian, Fedora, Alpine, in Docker and on macOS. The 08:00
Berlin report from the top, in /etc/runwisp/runwisp.toml:
[tasks.report]
cron = "0 8 * * *"
timezone = "Europe/Berlin"
run = "/usr/local/bin/report"
timeout = "20m"
Against the matrix: timezone per task, 6-field cron with optional leading seconds, L, W and
#, and the container’s env reaching the task by default (env_base = "inherit"). A run never
starts on top of the last one; it queues by default, or skips or replaces it with on_overlap.
Every run keeps its output and exit code. A typo doesn’t drop a file: runwisp validate points at
the line and column, and runwisp reload refuses a broken config and keeps the running one.
runwisp validate --explain prints each schedule in words with its next three runs, which is the
check that would have caught the CRON_TZ line.
To move existing jobs, runwisp import cron prints TOML for each crontab line, and include_cron
runs crontab files as they are. Both read CRON_TZ the way your distro’s cron does, so an
imported Ubuntu crontab keeps running on server time until you add a timezone. A crontab line
RunWisp can’t reproduce is skipped and reported by file and line, and the rest still runs.
The limits: it’s a daemon with its own config to learn, not a cron you drop in with the same syntax. It isn’t a DAG engine; if you need jobs that wait on other jobs, look at Dagu. And on a box where the stock cron is fine, a five-field crontab you understand beats a new tool. The cron comparison has the full side-by-side.
Common questions
Why is CRON_TZ not working on Ubuntu or Debian?
Does Alpine's crond read /etc/cron.d?
Why don't my Docker environment variables reach my cron job?
Why did my whole /etc/cron.d file stop running?
Is there a cron that works the same on every distro?
Read next
-
Ubuntu 26.04 Rust coreutils (uutils) vs GNU: what changes for cron and shell scripts
Which uutils commands behave differently from GNU coreutils on Ubuntu 26.04 and 26.10, tested on 567 commands: mkdir locks, mv and mtime, timeout, date and DST, df, chown. How to check which you have and how to use GNU instead.
-
When does cron's @reboot run? Not when your container restarts
@reboot runs the first time cron starts after /run was emptied. On a server that's once per boot. In Docker it's once per container, so a restart, or a host reboot with a restart policy, skips it. We tested six crons.