@reboot in Cron: How It Works and Why It Fails
Contents
@reboot runs a crontab command once, when the cron daemon starts, which on a normal server happens during boot. It isn't tied to the system being ready: cronie, Debian's cron, and BusyBox crond run @reboot lines right after loading their crontabs, possibly before the network is online, and a marker file in /run makes them skip those lines when the daemon is only restarted. Most @reboot problems follow from that timing, from cron's bare environment, and from the fact that nothing retries a failed run. For anything that must start reliably at boot, a systemd service is the better tool.
Behavior below comes from the source of cronie 1.7.2 (Fedora, RHEL and derivatives, Arch), Debian's cron 3.0pl1-197 (Debian 13; Ubuntu 24.04 and 26.04 carry the same @reboot patch), BusyBox crond 1.37.0 (Alpine 3.24), and the systemd 262 manuals, checked October 25, 2026.
Writing an @reboot Line#
@reboot replaces all five time fields. In a user crontab it's followed by the command; in /etc/crontab and /etc/cron.d files, by the user and then the command:
# crontab -e, as alice
@reboot /home/alice/bin/start-tunnel.sh >> /home/alice/tunnel.log 2>&1
# /etc/cron.d/warm-cache
@reboot root /usr/local/sbin/warm-cache >> /var/log/warm-cache.log 2>&1The redirect matters more than usual, for reasons covered below. cronie's crontab(5) describes the line as "Run once after reboot", and Debian's crontab(5) adds the caveat that matters: startup means when the cron daemon starts, which may be before other system services are up.
What @reboot Means in Each Implementation#
All three daemons load their crontabs and start the @reboot jobs immediately, without waiting for anything (cronie cron.c, Debian cron.c, BusyBox crond.c). To tell a boot from a daemon restart, each creates a marker file on its first start and skips @reboot lines when the file already exists:
| cronie 1.7.2 | Debian cron 3.0pl1-197 | BusyBox crond 1.37.0 | |
|---|---|---|---|
| Marker file | cron.reboot in its PID directory, /run/cron.reboot with systemd |
/run/crond.reboot |
crond.reboot in its PID directory: /var/run by default, /run on Alpine |
| Logged at boot | Nothing specific; each job logs a CMD line |
(CRON) INFO (Running @reboot jobs) |
Nothing specific; each job logs a USER ... cmd line |
| Logged on a daemon restart | (CRON) INFO (@reboot jobs will be run at computer's startup.) |
(CRON) INFO (Skipping @reboot jobs -- not system startup) |
Nothing |
| If the marker can't be made | Logs it, then runs the jobs on every daemon start | Logs DEATH and the daemon exits |
Skips the jobs without a message |
Sources: cronie cron.c and pathnames.h; Debian cron.c and pathnames.h; BusyBox crond.c and Alpine's build config. BusyBox 1.38.0 has the same code.
The marker works because /run is a tmpfs that "is flushed on boot", and /var/run is a compatibility symlink to it (UAPI file hierarchy). So @reboot means once per boot: neither systemctl restart cron nor systemd restarting the daemon after a crash (both units set Restart=on-failure) runs those lines again.
systemd itself has no crontab and no @reboot. The optional systemd-cron package, which turns crontabs into systemd units, converts an @reboot line into a timer with OnBootSec= set from the crontab's DELAY variable in minutes, one minute at least (generator, v2.8.2, L635-L638).
Why @reboot Jobs Fail at Boot#
Cron Doesn't Wait for the Network#
The cron daemon is an ordinary boot service. Fedora installs cronie's unit as crond.service and Arch as cronie.service (PKGBUILD), with After=auditd.service nss-user-lookup.target systemd-user-sessions.service time-sync.target ypbind.service autofs.service (cronie.systemd, Fedora spec). Debian's and Ubuntu's cron.service has After=remote-fs.target nss-user-lookup.target (cron.service). Neither names network-online.target, the target for "units that strictly require a configured network connection" (systemd.special(7)). network.target wouldn't help: "whether any network interfaces are already configured when it is reached is not defined" (Running Services After the Network Is Up).
So an @reboot job that calls an API, runs git pull, or opens a tunnel can start before DHCP or DNS is ready, fail once, and stay failed until the next boot.
Network file systems in /etc/fstab are covered by accident. Each such mount is ordered after network-online.target and before remote-fs.target (systemd.mount(5)), and both daemons start after remote-fs.target: Debian's directly, cronie's through systemd-user-sessions.service. On a host with an NFS or CIFS line in fstab, the network is up by the time @reboot jobs run. Without one, nothing makes cron wait; unless something requires it, network-online.target "is not pulled into the boot" at all (Running Services After the Network Is Up). A mount marked nofail is "not ordered before these target units" (systemd.mount(5)), so it may not be mounted yet either.
Mounts and Home Directories That Aren't There Yet#
Crontabs live under /var/spool/cron, not in the user's home, so a user's @reboot line loads even when the home directory isn't available. Then the daemons differ. cronie changes to $HOME before running the command, and if it can't, logs (CRON) ERROR chdir failed (/home/alice): No such file or directory and drops the job (security.c). Debian's cron ignores a failed change of directory and runs the job from its own working directory, /var/spool/cron (do_command.c, misc.c), so relative paths resolve somewhere unexpected. If the directory exists but is still an empty mount point, or is encrypted and unlocked only when its owner logs in, both run the job against whatever is there.
Cron's Environment, Before Anyone Logs In#
An @reboot job gets the same environment as any other cron job: a short PATH (/usr/bin:/bin for Debian's cron), no ~/.profile or ~/.bashrc, and only the variables cron sets. Cron environment variables lists each implementation's defaults. At boot there's also no login session: no desktop and no DISPLAY, and no per-user service manager unless lingering is enabled, since loginctl(1) spawns one "at boot" only for lingering users. GUI programs and systemctl --user have nothing to connect to. Use absolute paths and set what the script needs inside it.
Output Mail at Boot#
cronie and Debian's cron mail a job's output to MAILTO, or to the crontab's owner, through /usr/sbin/sendmail. With no mail transfer agent (MTA) installed, Debian's cron discards the output and cronie logs it to syslog instead (cron "No MTA installed"). With one installed, boot timing decides. Postfix's sendmail relies on postdrop "to create a queue file in the maildrop directory" (sendmail(1)), so the message waits there for Postfix. msmtp "reads a mail from standard input and sends it to a predefined SMTP server" (msmtp manual), which fails if the network isn't up yet. Redirect @reboot output to a file so the evidence doesn't depend on mail.
Nothing Retries a Failed Run#
Once an @reboot job exits, cron is done with it until the next boot, and restarting the daemon won't rerun it because the marker is still there. To rerun it, run the command yourself, ideally with cron's environment. Deleting the marker and restarting the daemon (rm /run/crond.reboot && systemctl restart cron on Debian) also works, but it reruns every @reboot job on the machine, for every user.
Check Whether @reboot Ran#
Confirm the daemon starts at boot, then read this boot's cron messages. The daemon logs under its own name and each job's line under the name in capitals (Debian do_command.c, cronie do_command.c), and journalctl -t takes several identifiers (journalctl(1)):
# Debian and Ubuntu
systemctl is-enabled cron
journalctl -b -t cron -t CRON
stat -c %y /run/crond.reboot # when this boot's @reboot jobs started
# Fedora and RHEL (on Arch the unit is cronie.service)
systemctl is-enabled crond
journalctl -b -t crond -t CRONDOn Debian, a boot that ran an @reboot job looks like this:
Oct 25 06:01:12 web1 cron[612]: (CRON) INFO (Running @reboot jobs)
Oct 25 06:01:12 web1 CRON[640]: (alice) CMD (/home/alice/bin/start-tunnel.sh >> /home/alice/tunnel.log 2>&1)cronie logs (CRON) STARTUP (1.7.2) among its startup lines, followed by the same kind of CMD line. A CMD line means cron started the command, not that it succeeded; the job's own log has the result. -b covers the current boot only, and earlier boots are there only if the journal is persistent. On Alpine, BusyBox crond logs USER root pid 2301 cmd ... to syslog for each job it starts (crond.c).
The sleep Hack and a Sturdier Wait#
The usual workaround is a fixed delay:
@reboot sleep 60 && /opt/app/bin/sync-feedsIt's a guess. A boot that waits on a slow DHCP server, a file system check, or a sluggish NFS server can take longer than 60 seconds, and then the job fails exactly as before; a fast boot wastes the minute. The delay checks nothing, and when the job fails there's no second attempt.
If the job has to stay in cron, wait for the thing it needs, with a deadline, and log the outcome. This script waits up to five minutes for its server's name to resolve:
#!/bin/sh
# /usr/local/bin/sync-feeds-at-boot
deadline=$(( $(date +%s) + 300 ))
until getent hosts feeds.example.com > /dev/null; do
if [ "$(date +%s)" -ge "$deadline" ]; then
echo "sync-feeds: feeds.example.com did not resolve within 5 minutes" >&2
exit 1
fi
sleep 5
done
exec /opt/app/bin/sync-feedsIn root's crontab: @reboot /usr/local/bin/sync-feeds-at-boot >> /var/log/sync-feeds.log 2>&1. For a mount, test mountpoint -q /srv/media instead. It's still a single attempt per boot, which is why the units below are the better fix.
Replace @reboot With a systemd Unit#
A Service That Starts at Boot and Stays Up#
A long-running process started with @reboot (a worker, a tunnel, a dev server) belongs in a service unit:
# /etc/systemd/system/app-worker.service
[Unit]
Description=App queue worker
Wants=network-online.target
After=network-online.target
RequiresMountsFor=/srv/media
[Service]
Type=exec
User=app
ExecStart=/opt/app/bin/worker
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now app-worker.serviceThen delete the @reboot line so the next boot doesn't start a second copy.
Wants=andAfter=network-online.targetare what systemd.special(7) asks of units that need the network. "Up" is "defined by the network management software", usually "a configured, routable IP address", and the wait happens only if its wait service,NetworkManager-wait-online.serviceorsystemd-networkd-wait-online.service, is enabled (Running Services After the Network Is Up). It doesn't prove a remote server answers, so keep retries in the worker.RequiresMountsFor=addsRequires=andAfter=on every mount needed to reach the path (systemd.unit(5)).Type=exec, systemd 240 and later, makes the start fail visibly if the binary can't be executed (systemd.service(5)).Restart=on-failurerestarts after a non-zero exit, most signals, or a timeout.RestartSec=defaults to 100ms, and restarts count against the default start limit of five starts in ten seconds (systemd-system.conf(5)), after which systemd stops trying. Five seconds between restarts stays under it.
To hear about crashes, add OnFailure= to [Unit], which starts another unit whenever this one enters the failed state; systemd timers shows a notification handler. With the default RestartMode=normal a crashing service passes through the failed state on every restart (systemd.service(5)), so it fires for each crash, not only when restarts stop.
A One-Time Task at Each Boot#
For a command that runs once and exits, use Type=oneshot with RemainAfterExit=yes. Without it, "a one-shot service may be started again after exiting successfully, for example as a dependency in another transaction" (systemd 245 NEWS):
# /etc/systemd/system/warm-cache.service
[Unit]
Description=Warm the application cache once per boot
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
User=app
ExecStart=/opt/app/bin/warm-cache
TimeoutStartSec=15min
[Install]
WantedBy=multi-user.targetsystemctl status warm-cache then shows active (exited) after a successful run this boot, or failed. A oneshot has no start timeout by default, hence TimeoutStartSec=. Because a target is ordered after the units it wants (systemd.target(5)), a slow oneshot also delays the point where multi-user.target is reached; move long work to a timer.
For a per-user process, write a user unit with WantedBy=default.target and run loginctl enable-linger alice so the user's manager starts at boot; the systemd timers guide covers lingering too.
Recurring Work That Starts After Boot#
When @reboot is the first of many runs, a timer replaces it and the repeating schedule together:
# /etc/systemd/system/sync-feeds.timer
[Unit]
Description=Sync feeds two minutes after boot, then every 15 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=15min
[Install]
WantedBy=timers.targetThe timer starts sync-feeds.service, a oneshot like the one above minus two parts. Drop RemainAfterExit=yes: a timer doesn't restart a unit that's still active, so the service would run only once. Drop the [Install] section too, since the timer is what you enable. OnUnitActiveSec= counts from the service's last start, and an OnBootSec= time that has already passed when the timer is activated "will immediately elapse" (systemd.timer(5)). systemd timers vs cron covers moving the rest of a crontab.
@reboot in Docker Containers#
Inside a container, @reboot means the moment crond starts in that container, and only if the container runs a cron daemon. The host's @reboot has nothing to do with it, and a cron daemon as the container's main process brings its own PID 1 problems (cron in Docker).
The marker file lives in the container's own /run. "When the container is deleted, the writable layer is also deleted" (Docker storage drivers), so each docker run, each Compose recreate, and each replica runs the @reboot jobs again. After a crash, Kubernetes "restarts the container with a clean state" (Volumes), so they run again after every crash there. With --tmpfs /run, "when the container stops, the tmpfs mount is removed" (tmpfs mounts), so every start runs them.
For start-up work in a container, use the entrypoint: run the step, then exec the main process so it keeps PID 1:
#!/bin/sh
# docker-entrypoint.sh
set -e
/app/bin/migrate
exec "$@"Monitoring Something That Runs at Boot#
A schedule-based monitor needs a time when a run is due, and a boot task has none: it runs whenever the machine starts. CronRadar compares check-ins with a five-field cron schedule evaluated in UTC and doesn't accept @reboot or any other @ shortcut, so it isn't the tool for an @reboot job itself.
For a boot-time oneshot, systemd's state is the signal: a failed run stays failed in systemctl --failed, and OnFailure= can notify you. For a long-running service, Restart= recovers crashes and OnFailure= reports them. If you also want an outside check that the service keeps working, including when the host never comes back, have the service send a heartbeat at fixed clock times and monitor that heartbeat on a schedule such as */5 * * * *. Send it on the schedule's marks, not every five minutes counted from the service's start: each check-in moves the expected time to the schedule's next occurrence, so a heartbeat that drifts off the marks shows as late every cycle, and as critical once the drift is past the grace period and at least two minutes. There's no CronRadar SDK for systemd units or shell scripts and no auto-discovery for them; each heartbeat is an HTTP request, as how to monitor cron jobs shows.
Frequently Asked Questions#
Why is my @reboot cron job not running?#
Check that the cron daemon is enabled (systemctl is-enabled cron, or crond on Fedora and RHEL), then look in this boot's journal for the job's CMD line. If the line is there, the job started and failed, usually because the network, a mount, or a command on its PATH wasn't available yet; redirect its output to a file to see the error. If it isn't, remember that @reboot lines run only at the daemon's first start after boot, not when you add the line or restart cron.
Does @reboot run when cron restarts?#
No, not in cronie, Debian's cron, or BusyBox crond. Each creates a marker file in /run on its first start and skips @reboot lines while that file exists, and /run is emptied at boot. Containers behave differently: a new container has no marker, so it runs the @reboot jobs again.
How do I delay an @reboot job until the network is up?#
In cron, have the job wait for what it needs, such as its server's name resolving, in a loop with a deadline instead of a fixed sleep. The reliable fix is a systemd service with Wants=network-online.target and After=network-online.target, which systemd holds until the network manager reports the network as up. Keep a retry in the job either way, since "up" usually means an address is configured, not that a remote service answers.
Does @reboot work in Docker?#
Only if a cron daemon runs inside the container, and then it fires when that daemon starts, not when the host boots. Every new container runs the @reboot jobs again because it starts without the marker file. For start-up work, run the command in the image's entrypoint before execing the main process.
Is systemd better than @reboot?#
For anything that must start reliably at boot, yes. A unit can wait for the network and for specific mounts, restart after a crash, log to the journal, and report failures through OnFailure=; @reboot starts the command once when cron starts and does nothing else. It's still reasonable for a quick, harmless task, or on a system without systemd, such as Alpine with OpenRC.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.