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.
7 min read
You put @reboot /usr/local/bin/ddns-update in a crontab inside a container. It ran the first time.
Then the host rebooted for a kernel update, the container came back on its own, and the job didn’t
run. Nothing in any log says it was skipped.
@reboot doesn’t know what a reboot is. Each cron decides by looking for a file it left in /run the
last time it started. We ran the same @reboot line through six crons, restarted each one, restarted
its container, and checked what ran.
Short answer
@reboot runs the first time cron starts since /run was emptied. On a normal Linux host /run is
a tmpfs, so that’s once per boot, and restarting cron doesn’t repeat it. In a container /run lives in
the container’s writable layer: docker restart, docker compose restart and a restart policy after
a host reboot all keep it, so @reboot only runs again when the container is recreated. Add
tmpfs: [/run] to the service, or run the command from the entrypoint. @reboot also doesn’t wait
for the network, so retry anything that needs it.
If you’d rather not reason about marker files, RunWisp, an open-source cron replacement that ships as
a single binary, has run_on_start = "boot": once per machine boot, once per container start, with
retries. Setup below.
| Cron | Marker file | Cron restarted | Container restarted | Container recreated | |
|---|---|---|---|---|---|
| Debian 13 | Debian cron 3.0pl1 | /run/crond.reboot | skipped | ✗ skipped | runs |
| Ubuntu 26.04 | Debian cron 3.0pl1 | /run/crond.reboot | skipped | ✗ skipped | runs |
| AlmaLinux 10 | cronie 1.7.0 | /run/cron.reboot | skipped | ✗ skipped | runs |
| Fedora 44 | cronie 1.7.2 | /run/cron.reboot | skipped | ✗ skipped | runs |
| Arch | cronie 1.7.2 | /run/cron.reboot | skipped | ✗ skipped | runs |
| Alpine 3.24 | BusyBox 1.37 crond | /run/crond.reboot | skipped | ✗ skipped | runs |
| RunWisp 1.7 (ours) | "boot" task | boot ID in its DB | skipped | ✓ runs | runs |
Skipping on a cron restart is what you want: apt upgrade restarting cron shouldn’t remount your
disks. Skipping on a container restart usually isn’t.
How cron decides it’s a boot
When cron starts, it checks for its marker file. If it’s missing, cron creates it and runs every
@reboot job it knows about, in system and user crontabs alike. If it’s there, cron runs
none of them. That’s the whole mechanism on all three implementations:
$ ls /run
crond.pid crond.reboot
Nothing deletes the file except whatever empties /run. On systemd distros that’s the boot, because
/run is a tmpfs. It also means a cron that starts for the first time hours after boot, because you
just installed it or it was disabled, still runs @reboot jobs then. “Startup”, as Debian’s
crontab(5) puts it, “is the time when the cron daemon startup.”
In Docker, a restart isn’t a boot
We started cron in each container, restarted it, then restarted the container with Podman 5.7.
Docker does the same thing here: neither mounts a tmpfs on /run unless you ask. The job appended
the date to a log:
$ podman exec app cat /var/log/reboot.log
Mon Oct 19 12:55:00 UTC 2026
$ podman restart app
$ podman exec app cat /var/log/reboot.log
Mon Oct 19 12:55:00 UTC 2026
$ podman exec app ls /run
crond.pid crond.reboot
Still one line, on all six. It was the same with cron as PID 1, the usual container setup (we tried
cron -f, crond -f and crond -n). Things that restart a container and keep /run:
docker restart,docker stopthendocker startdocker compose restart,docker compose stopthenstartrestart: unless-stoppedoralwaysbringing it back after the Docker daemon or the host restarts
Things that make a new container, so @reboot runs: docker compose up -d after the image or
config changed, --force-recreate, docker compose down then up, docker rm then docker run.
That’s why it looks random: it ran after every deploy and stopped after a host reboot.
Fix 1: put a tmpfs on /run
services:
app:
image: my-app
tmpfs:
- /run
docker run --tmpfs /run is the same thing. With it, every container restart starts with an empty
/run, and @reboot ran again after a restart on Debian, Fedora and Alpine. Anything your image writes to
/run at build time is hidden behind the tmpfs, so check that first.
Fix 2: skip @reboot and use the entrypoint
A container already has a hook that runs on every start. Do the work there, then hand over to cron:
#!/bin/sh
/usr/local/bin/ddns-update || echo "ddns-update failed: exit $?" >&2
exec cron -f
The || keeps a failed update from stopping cron from starting, and the error ends up in
docker logs. For a whole compose stack’s jobs, see
Docker Compose cron jobs with a web UI.
@reboot doesn’t wait for the network
Cron’s own startup is all @reboot waits for, and none of the cron units wait for the network:
| Distro | Cron unit is ordered after |
|---|---|
| Debian 13, Ubuntu | remote-fs.target nss-user-lookup.target |
| Fedora, Alma, Arch | auditd.service nss-user-lookup.target systemd-user-sessions.service time-sync.target … |
| Alpine (OpenRC) | need localmount, need logger |
None of them mention network-online.target, so a job that resolves a hostname can fail with
Could not resolve host on one boot and work on the next, depending on how fast DHCP was.
The usual answer is @reboot sleep 60 && .... It wastes a minute on a fast boot and isn’t enough on
a slow one. Retry the thing you actually need instead:
#!/bin/sh
# /usr/local/bin/ddns-update-retry: try for up to 5 minutes
for i in $(seq 30); do
/usr/local/bin/ddns-update && exit 0
sleep 10
done
echo "ddns-update: gave up after 30 tries" >&2
exit 1
@reboot /usr/local/bin/ddns-update-retry >> /var/log/ddns.log 2>&1
@reboot jobs also get cron’s bare environment, like any other job: a short PATH and no
.bashrc. If it works from your shell and not from cron, this checklist
covers it. And if it fails, cron tells nobody; here’s how to get alerted.
Test an @reboot job without rebooting
Delete the marker and restart cron:
$ sudo rm /run/crond.reboot && sudo systemctl restart cron # Debian, Ubuntu
$ sudo rm /run/cron.reboot && sudo systemctl restart crond # RHEL, Fedora, Arch: cronie
$ rm /run/crond.reboot && rc-service crond restart # Alpine
That ran the job again on Debian, Fedora and Alpine. It runs every @reboot job on the machine,
including other users’, so check sudo grep -rs @reboot /etc/crontab /etc/cron.d /etc/crontabs /var/spool/cron first.
Or use something that knows what a boot is
A systemd service
On a systemd host, a oneshot service is the native “run once at boot”, and it can wait for the
network. /etc/systemd/system/ddns-update.service:
[Unit]
Description=Update dynamic DNS at boot
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/ddns-update
[Install]
WantedBy=multi-user.target
$ sudo systemctl enable ddns-update.service
network-online.target only waits if NetworkManager-wait-online.service or
systemd-networkd-wait-online.service is enabled; most installs enable the one that matches their
network manager. Its output and exit code are in journalctl -u ddns-update. It doesn’t help inside a
container, where systemd usually isn’t running. More in RunWisp vs systemd timers.
RunWisp
RunWisp (our project, so weigh accordingly) is an open-source cron replacement that ships as a single
binary. A task with run_on_start = "boot" is the @reboot equivalent, in
/etc/runwisp/runwisp.toml:
[tasks.ddns-update]
run_on_start = "boot"
run = "/usr/local/bin/ddns-update"
retry_attempts = 5
retry_delay = "10s"
Instead of a marker file, RunWisp stores the kernel’s boot ID plus the start time of PID 1. On a host, that changes once per boot. In a container, PID 1 is the container’s own first process, so every container start counts as a boot without detecting containers at all. We ran it through the same steps as cron:
run_on_start | Daemon restarted | Container restarted |
|---|---|---|
"boot" | skipped | runs |
true | runs | runs |
The retries cover the network. With an update that couldn’t resolve its host twice:
[WARN] run failed task=ddns-update exit=6 reason=failed dur=40ms
[INFO] Scheduling retry attempt=1 max=5 task=ddns-update delay=10s
[WARN] run failed task=ddns-update exit=6 reason=failed dur=35ms
[INFO] Scheduling retry attempt=2 max=5 task=ddns-update delay=10s
[INFO] run succeeded task=ddns-update exit=0 dur=36ms
Every attempt keeps its output and exit code, in runwisp logs ddns-update, the terminal UI and the
web UI. Add notify = ["phone"] and a notifier to get an alert when a run fails. To move existing
jobs over, runwisp import cron turns @reboot lines into run_on_start = "boot" tasks and prints
the TOML for you to review:
$ crontab -l | runwisp import cron
The limits: RunWisp’s own systemd unit starts after network.target, not network-online.target,
so a boot task can still run before the network is up and you lean on the retries. A task added with
runwisp reload doesn’t run until the next boot or runwisp restart. And it’s one more service to
keep running, which @reboot isn’t. The cron comparison has the rest of the mapping.
Common questions
Does @reboot run when the cron service is restarted?
Why doesn't my @reboot cron job run in Docker?
Does @reboot wait for the network to be up?
How do I test an @reboot cron job without rebooting?
Is there something better than @reboot for running a job at boot?
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.
-
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.