← all posts

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.

3 of 6distros honored CRON_TZ: the cronie ones
1 linedisables a whole /etc/cron.d file on Debian and Ubuntu
61in the minute field ran every minute on Alpine
1 of 6passed Docker env vars to the job

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 thisIf it printsYou have
dpkg -S "$(readlink -f "$(command -v cron)")"cron: /usr/sbin/cronDebian’s cron (Debian, Ubuntu, Pi OS)
crond -Vcronie 1.7.2cronie (Fedora, RHEL, Alma, Arch)
crond --help 2>&1 | head -1BusyBox v1.37.0 (…)BusyBox crond (Alpine)
crond -h 2>&1 | head -1dillon's cron daemon 4.6dcron (Alpine’s dcron package)
supercronic -versiona version numbersupercronic

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, UbuntucronieBusyBox (Alpine)supercronicRunWisp (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 codewith -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
Cronutctzcrontz
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
CronWhat ran after two minutes
Debian 13, Ubuntu 26.04nothing
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/sendmail isn’t there at startup, it switches itself to logging output to syslog, one CMDOUT line per output line.
  • BusyBox: without a MAILTO= line, output goes to crond’s own stdout. Run crond -f and it shows up in docker 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: -n mails only on failure, -q skips the log line, -s runs one instance at a time. ~ random fields arrived in OpenBSD 6.7. No /etc/cron.d.
  • dcron (Alpine’s dcron package) treats @daily as “24 hours since the last run”, not midnight, and drops the line unless it has an ID=. 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~7 is 5 and 8), ANDs day-of-month and day-of-week by default, and has a real per-job timezone(...) 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:

  1. Five fields, numbers, *, ,, - and / only. No seconds, L, W, #, ~ or day names in ranges.
  2. Assume the server’s time zone. No CRON_TZ. Keep servers in UTC.
  3. Set env inside the command: cd /srv/app && FOO=bar ./job, or source a file.
  4. Use absolute paths for everything; PATH is different on all three.
  5. Lock and time out inside the command: flock -n /run/lock/job.lock timeout 20m ./job.
  6. Redirect output yourself: >> /var/log/job.log 2>&1, since nobody else keeps it.
  7. Name files in /etc/cron.d with letters, digits, - and _, mode 644, owned by root, ending in a newline. On Alpine, use /etc/crontabs/root instead.

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?
Because Debian's cron, which Ubuntu and Raspberry Pi OS also ship, doesn't support it. The string CRON_TZ doesn't appear in its source or its man pages, and TZ= in a crontab only changes the job's environment, not when it runs. Every job is scheduled in the time zone cron itself runs in. CRON_TZ is a cronie feature (Fedora, RHEL, AlmaLinux, Arch). On Debian and Ubuntu, either set the system time zone, convert the schedule to the server's zone by hand, or schedule the job in a scheduler that has per-job time zones, such as a systemd timer (OnCalendar=*-*-* 09:00 Europe/Berlin).
Does Alpine's crond read /etc/cron.d?
No. Alpine's crond is BusyBox crond, which only reads per-user crontabs from /etc/crontabs (/var/spool/cron/crontabs is a symlink to it). Files in /etc/cron.d are ignored without a log line, and a file in /etc/crontabs is also ignored if it isn't owned by root, if its name isn't an existing user, or if the name contains a dot. Put jobs in /etc/crontabs/root, or use the /etc/periodic/ directories that root crontab already runs.
Why don't my Docker environment variables reach my cron job?
Debian's cron and cronie start every job with an empty environment and add only HOME, SHELL, LOGNAME, PATH, the crontab's own NAME=value lines and whatever PAM's pam_env adds. Variables passed with docker run -e or compose environment: are set on the cron process and never reach the job. BusyBox crond (Alpine) and supercronic do pass the daemon's environment through. On Debian-based images, either write the variables to a file at container start and source it in the job, or switch to supercronic.
Why did my whole /etc/cron.d file stop running?
On Debian and Ubuntu, one line cron can't parse (a bad field, an unquoted empty variable on Ubuntu 24.04, a command over 999 characters) or a missing newline at the end of the file disables the entire file. Cron logs 'this crontab file will be ignored' to syslog and runs nothing from it. Files whose names contain a dot, like backup.cron, are skipped with no log line at all. cronie only drops the bad line.
Is there a cron that works the same on every distro?
Not one that ships as the default anywhere. supercronic is a single binary built for containers that behaves the same on every image, with a 7-field syntax that includes seconds. RunWisp, an open-source cron replacement that ships as a single binary, also runs the same on every Linux distro and macOS, with a per-task timezone, 6-field cron with seconds, L, W and #, and config validation that points at the bad line instead of dropping a file. Its limits: it's a daemon with its own TOML config rather than a crontab reader you drop in, and moving existing jobs takes a runwisp import or include_cron step.
  1. 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.

  2. 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.

Get started
GPL 3.0 · no accounts · free to self-host

Start in 30 seconds. Ship on your terms.

A single Go binary. If it doesn't earn the disk space, rm it. No account, no signup, no catch.

Install in 30 seconds
curl -fsSL https://get.runwisp.com | sh