← all posts

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.

6 of 6crons skipped @reboot after a container restart
0 of 5cron systemd units that wait for the network
1 fileto delete to test @reboot without rebooting

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.

CronMarker fileCron restartedContainer restartedContainer recreated
Debian 13Debian cron 3.0pl1/run/crond.rebootskipped✗ skippedruns
Ubuntu 26.04Debian cron 3.0pl1/run/crond.rebootskipped✗ skippedruns
AlmaLinux 10cronie 1.7.0/run/cron.rebootskipped✗ skippedruns
Fedora 44cronie 1.7.2/run/cron.rebootskipped✗ skippedruns
Archcronie 1.7.2/run/cron.rebootskipped✗ skippedruns
Alpine 3.24BusyBox 1.37 crond/run/crond.rebootskipped✗ skippedruns
RunWisp 1.7 (ours)"boot" taskboot ID in its DBskipped✓ runsruns

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 stop then docker start
  • docker compose restart, docker compose stop then start
  • restart: unless-stopped or always bringing 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:

DistroCron unit is ordered after
Debian 13, Ubunturemote-fs.target nss-user-lookup.target
Fedora, Alma, Archauditd.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_startDaemon restartedContainer restarted
"boot"skippedruns
truerunsruns

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?
No, on every cron we tested. Debian and Ubuntu's cron and BusyBox crond create /run/crond.reboot the first time they start, cronie (RHEL, Fedora, Arch) creates /run/cron.reboot, and while that file exists a restarted cron skips @reboot jobs. /run is a tmpfs on a normal Linux host, so the file is gone after the next boot and @reboot runs again then.
Why doesn't my @reboot cron job run in Docker?
It runs once, when the container is created, and then not again. /run inside a container is part of its writable layer, not a tmpfs, so the marker file cron left there survives docker restart, docker stop and start, docker compose restart, and the restart policy bringing the container back after a host reboot. Only a recreated container (docker compose up --force-recreate, down and up, or a new image) runs @reboot again. Mount a tmpfs on /run (tmpfs: [/run] in compose) or run the command from the entrypoint instead.
Does @reboot wait for the network to be up?
No. @reboot runs as soon as cron starts, and none of the cron units on Debian, Ubuntu, Fedora, AlmaLinux or Arch are ordered after network-online.target. Debian's crontab(5) man page warns that @reboot may run before other daemons have started. Retry the command until it works instead of adding a fixed sleep, or run it from a systemd service with Wants= and After=network-online.target.
How do I test an @reboot cron job without rebooting?
Delete cron's marker file and restart cron: sudo rm /run/crond.reboot && sudo systemctl restart cron on Debian and Ubuntu, sudo rm /run/cron.reboot && sudo systemctl restart crond on cronie distros, rm /run/crond.reboot && rc-service crond restart on Alpine. Every @reboot job on the machine runs again, including other users' crontabs.
Is there something better than @reboot for running a job at boot?
A systemd service with Type=oneshot and WantedBy=multi-user.target runs once per boot and can wait for network-online.target. RunWisp, an open-source cron replacement that ships as a single binary, has run_on_start = "boot": once per machine boot, a daemon restart doesn't repeat it, and every container start counts as a boot. It can retry a failed boot task and alert on it. Its limits: it doesn't wait for network-online.target either, so you lean on the retries, and it's one more service to run.
  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. 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.

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