← 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. Scheduling restic or borg backups with alerts when they fail: systemd timer, cron or borgmatic

    Four ways to run restic or borg on a schedule and hear about it when a backup fails or never runs: a systemd timer with OnFailure=, cron with a Healthchecks ping, resticprofile and borgmatic. Tested with restic 0.18 and 0.19, including exit 3, stale locks and catch-up after downtime.

  2. Django periodic tasks without Celery or Redis: 5 Docker Compose setups

    You need clearsessions every night and a report every 15 minutes, and every tutorial adds Celery, beat and Redis. Five setups without them, each with a Compose file: a management command plus supercronic, APScheduler (and why it runs 3 times under gunicorn), Huey on SQLite, django-q2 on your database, and RunWisp.

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