Automate Recurring Work With Cron and systemd Timers
Automate tasks with cron and systemd timers: crontab syntax, timer units, locking, logging, missed runs, time zones and how to move from one to the other.

Scheduling is where a script stops being something you run and becomes something that runs. Linux gives you two main ways to do it. Cron has been scheduling commands since the 1970s and is still on nearly every system. A systemd timer is the newer mechanism on systems that use systemd, and it fixes most of cron's rough edges at the cost of two files per job. This guide covers both, with the failure modes of each, and ends with how to choose and how to move a job from one to the other. The examples ran on Debian 12 with cron 3.0pl1 and systemd 252, and the timer expressions were checked with systemd-analyze. The scripts being scheduled are written as in the Bash scripting tutorial.
The guide covers the cron model, the crontab syntax, the environment cron provides, logging and mail, overlapping runs, the same job as a systemd timer, calendar expressions, catching up after downtime, time zones, hardening the service, monitoring, and a set of real jobs.
What a scheduler does
A scheduler is a long-running process that wakes at the right moments and starts other programs. It does not run your job inside a login session, which is the root of most scheduling problems: the job has no terminal, no interactive shell, no ssh-agent, no desktop session and none of the environment variables you set in ~/.bashrc. Anything the script quietly depended on while you tested it by hand has to be stated explicitly. Keep that in mind throughout. The scheduler's responsibility ends at starting the program; everything after that, including noticing a failure, is yours to design.
Two kinds of schedule exist. Calendar schedules fire at wall-clock times such as "02:30 every day" and are what cron expresses. Monotonic schedules fire a duration after some event, such as "ten minutes after boot" or "one hour after the last run finished", and are available only in systemd timers. Most recurring operational work is calendar based. Work that should never overlap or pile up is better described by "an interval after the last finish", which is awkward in cron.
The crontab format
A crontab is a text file in which each line has five time fields, then the command. The fields are minute (0 to 59), hour (0 to 23), day of month (1 to 31), month (1 to 12 or names) and day of week (0 to 7, where both 0 and 7 are Sunday, or names). An asterisk means every value, a comma separates a list, a dash gives a range, and /n is a step.
# minute hour dom month dow command
30 2 * * * /usr/local/bin/backup.sh
*/15 * * * * /usr/local/bin/poll.sh
0 9 * * 1-5 /usr/local/bin/report.sh
0 4 1 * * /usr/local/bin/monthly.sh
@reboot /usr/local/bin/on-boot.sh
The first line runs at 02:30 every day. The second runs every 15 minutes. The third runs at 09:00 on weekdays. The fourth runs at 04:00 on the first of each month. The special @reboot string runs once when the cron daemon starts. Vixie cron and its descendants also accept @hourly, @daily, @weekly, @monthly and @yearly. The full format is documented in crontab(5).
The day-of-month and day-of-week trap
When both the day-of-month and day-of-week fields are restricted, standard cron runs the job when either matches, not when both do. A line meaning "the first Monday of the month", written as 0 9 1-7 * 1, actually runs on days 1 to 7 and also on every Monday. This is specified behavior, not a bug, and it surprises almost everyone once.
# runs on the 1st to the 7th AND every Monday
0 9 1-7 * 1 /usr/local/bin/job.sh
# the first Monday only: run daily on days 1 to 7 and test the weekday in the command
0 9 1-7 * * [ "$(date +\%u)" = 1 ] && /usr/local/bin/job.sh
The second form tests the weekday in the command itself. Note the escaped percent sign: in a crontab, an unescaped % in the command is converted to a newline, and everything after the first one is sent to the command as standard input. That is the cause of countless broken date +%F jobs. Either escape each percent with a backslash, or put the logic in a script file and call the script, which is the better habit anyway.
Edit and list crontabs
Each user has a crontab edited with crontab -e, listed with crontab -l and removed with crontab -r. The last one has no confirmation and sits one key away from -e on a keyboard, which has wiped many a schedule; keep your crontab in version control and install it from there. The system also reads /etc/crontab and files in /etc/cron.d/, which have an extra sixth field naming the user to run as. Only root can write those.
# /etc/cron.d/nightly-backup
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
[email protected]
30 2 * * * backup /usr/local/bin/backup.sh -k 7 /srv/data
Files in /etc/cron.d must be owned by root, not writable by group or others, and have names containing only letters, digits, underscores and hyphens. A file named backup.sh or backup.cron with a dot is silently ignored by Debian's cron, a rule that is a common source of the familiar complaint that a cron.d file does nothing. The same naming rule applies to the scripts dropped into /etc/cron.daily and its siblings, which are run by run-parts.
The environment cron gives you
Cron runs commands with HOME, LOGNAME and SHELL set, PATH limited to /usr/bin:/bin on Debian, and the shell set to /bin/sh, which is dash on Debian. It reads none of your dotfiles. That means three habits. Use absolute paths for every program and file. Set PATH and SHELL at the top of the crontab when the job needs them. And begin scripts with their own set -euo pipefail and explicit PATH, so they behave the same wherever they start.
#!/usr/bin/env bash
# Print what a scheduled job actually sees.
{
date
id
pwd
echo "SHELL=$SHELL"
echo "PATH=$PATH"
env | sort
} > /tmp/cron-env.txt 2>&1Schedule this script once, read /tmp/cron-env.txt afterwards, and compare it to your interactive env. The difference is the list of things your script will trip over. A working directory of $HOME instead of the script's directory is another frequent surprise: change into the right directory at the top of the script with cd -- "$(dirname -- "$0")".
Logging and mail
By default cron mails any output of a job to the owner, using the local mail system. On a server with no mail transfer agent configured, that output vanishes. The reliable pattern is to send the job's output where you decide, and to make failures loud.
30 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
30 2 * * * /usr/local/bin/backup.sh 2>&1 | logger -t backup
*/5 * * * * /usr/local/bin/poll.sh > /dev/null
The first appends both streams to a log file, which should be rotated with logrotate. The second sends them to syslog under a tag, so they end up in the journal on systemd hosts. The third discards standard output but leaves standard error, which cron would mail; that is a useful convention for jobs that print routine noise but whose errors you want to hear about. Set MAILTO= to an address, or to an empty string to disable mail. A job that fails silently is worse than a job that never ran, so decide where failure goes before you schedule anything.
Prevent overlapping runs
If a job takes longer than its interval, cron starts the next one anyway. Two backups writing the same archive, or two importers inserting the same rows, are a classic outage. The fix is a lock. flock from util-linux holds an advisory lock on a file for the life of the command and releases it when the process exits for any reason, including a crash.
*/5 * * * * flock -n /run/lock/poll.lock /usr/local/bin/poll.sh
#!/usr/bin/env bash
set -euo pipefail
exec 9>/run/lock/nightly.lock
if ! flock -n 9; then
echo "previous run still going, exiting" >&2
exit 0
fi
./do-the-work.shWith -n the second run exits at once; without it, it would wait for the lock and the runs would queue up. Avoid hand-made PID files, which survive a crash and then block the job until someone deletes them, or are removed by the wrong run. A lock owned by the kernel disappears with the process, so there is nothing to clean up.
Cron on a laptop or an unreliable host: anacron
Cron assumes the machine is up. If it is off at 02:30, the job is skipped, and nothing records that it should have run. anacron solves this for jobs measured in days: it keeps a timestamp per job, and when the system is up and a period has elapsed since the last run, it runs the job after a delay. On Debian, the files in /etc/cron.daily, /etc/cron.weekly and /etc/cron.monthly are run by anacron when it is installed, and by cron otherwise. Anacron cannot express times of day and works at a granularity of days, so it is a tool for housekeeping, not for "every five minutes".
# /etc/anacrontab
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
# period delay job-id command
1 5 backup /usr/local/bin/backup.sh
7 10 cleanup /usr/local/bin/cleanup.sh
The period is in days and the delay in minutes after anacron starts. For anything that needs catch-up on a modern system, a systemd timer with Persistent=true gives the same behavior with one line, which is a major reason to prefer timers on laptops and development machines.
What a systemd timer is
A timer is a unit with the suffix .timer that activates another unit, normally a service with the same base name, when its schedule fires. The timer holds the schedule; the service holds the command, user, environment and limits. Splitting them looks like extra work for one job and pays off as soon as you want to start the job by hand, with systemctl start backup.service, or read its logs with journalctl -u backup.service, because those work on the service independent of the schedule.
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup of /srv/data
[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/backup.sh -k 7 /srv/data# /etc/systemd/system/backup.timer
[Unit]
Description=Run the nightly backup
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=5min
[Install]
WantedBy=timers.targetEnable the timer, not the service: systemctl daemon-reload then systemctl enable --now backup.timer. The service has no [Install] section because it is never enabled on its own. The unit file format is described in systemd.timer(5) and systemd.service(5).
Calendar expressions
OnCalendar= takes the form DayOfWeek Year-Month-Day Hour:Minute:Second, where each part is optional and can use *, lists with commas, ranges with .. and repetition with /. There are shorthands such as hourly, daily, weekly, monthly. The tool systemd-analyze calendar parses an expression, shows its normalized form and prints the next time it fires, which removes the guessing.
systemd-analyze calendar 'Mon..Fri *-*-* 09:00'
systemd-analyze calendar '*:0/15'
systemd-analyze calendar weekly Original form: Mon..Fri *-*-* 09:00
Normalized form: Mon..Fri *-*-* 09:00:00
Next elapse: Fri 2026-11-06 09:00:00 UTC
Original form: *:0/15
Normalized form: *-*-* *:00/15:00
Next elapse: Fri 2026-11-06 06:00:00 UTC
Original form: weekly
Normalized form: Mon *-*-* 00:00:00
Next elapse: Mon 2026-11-09 00:00:00 UTC
Run the analyzer for every schedule before you deploy it, and add --iterations=5 to see several future elapses. Unlike cron, a calendar expression with both a weekday and a day of the month requires both to match, so "the first Monday" is expressible directly as Mon *-*-01..07 09:00, which cron cannot do without the shell test shown above.
Monotonic timers
Four directives schedule relative to an event instead of the clock. OnBootSec= counts from boot, OnStartupSec= from when the service manager started, OnActiveSec= from when the timer itself was activated, and OnUnitActiveSec= or OnUnitInactiveSec= from when the target service was last started or finished. Combine them to build "five minutes after boot, then every hour".
[Timer]
OnBootSec=5min
OnUnitInactiveSec=1h
AccuracySec=1minUsing OnUnitInactiveSec measures from the end of the previous run, so runs never overlap and never queue, no matter how long the job takes. That is exactly the property cron needs a lock to emulate. Durations accept unit suffixes, and you can verify a value with systemd-analyze timespan "1h 30min", which printed 5400000000 microseconds on the test host.
Persistent timers and missed runs
With Persistent=true, systemd stores the last trigger time on disk, in /var/lib/systemd/timers/. If the machine was off or the timer was inactive when a calendar event should have fired, it triggers the service as soon as the timer is next activated. The feature applies only to OnCalendar= timers. It catches up once, not once for every missed day: three missed nights produce one run on boot.
systemctl list-timers --all
systemctl status backup.timer
cat /var/lib/systemd/timers/stamp-backup.timerNEXT LEFT LAST PASSED UNIT ACTIVATES
Sat 2026-11-07 02:33:41 UTC 20h left Fri 2026-11-06 02:31:07 UTC 3h ago backup.timer backup.service
The listing shows the next trigger, the last one, and the service each timer starts, which makes it the first stop in any "did it run?" investigation. A job that must run exactly once a day even on a machine that is often off is the clearest case for a persistent timer.
Spreading load: RandomizedDelaySec and AccuracySec
When hundreds of machines all run a job at 02:00, the service it contacts sees a spike. RandomizedDelaySec= adds a random delay up to the stated amount to each trigger, so the fleet spreads out. AccuracySec= lets systemd coalesce wake-ups: the default is one minute, which means a timer may fire up to a minute late to share a wake-up with others. Set AccuracySec=1s when the minute matters. FixedRandomDelay=true, added in systemd 247, keeps a host's delay the same between runs, so each machine has a stable but different time.
[Timer]
OnCalendar=daily
RandomizedDelaySec=30min
FixedRandomDelay=true
AccuracySec=1sCron has no equivalent. The common workaround is a sleep $((RANDOM % 1800)) at the start of the job, which holds a process for half an hour and is invisible to monitoring until it starts doing work.
Writing the service unit well
A scheduled job should be a Type=oneshot service: systemd considers it active while the command runs, and a second activation request while it is active does not start another copy. Set the user so the job does not run as root without reason. Set the working directory, an environment file if needed, and a timeout so a hung job is killed.
[Unit]
Description=Sync reports to object storage
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=reports
WorkingDirectory=/srv/reports
EnvironmentFile=/etc/reports/env
ExecStart=/usr/local/bin/sync-reports.sh
TimeoutStartSec=30min
Nice=10
IOSchedulingClass=idleThe After= and Wants= lines make the job wait for the network, a dependency cron cannot state at all. EnvironmentFile holds KEY=value lines, which keeps secrets out of the unit file and out of the process list. Nice and IOSchedulingClass=idle keep a heavy job from slowing foreground work. Because systemd runs the command without a shell, pipes, redirections and $VARIABLES in ExecStart are not interpreted; call a script, or use /bin/bash -c '...' deliberately.
Hardening the job
Because a service unit is a sandbox definition, you can restrict a scheduled job to what it needs with a few lines. This has no cron counterpart short of running the job in a container. Useful directives include ProtectSystem=strict (read-only filesystem except listed paths), ReadWritePaths=, PrivateTmp=true, NoNewPrivileges=true, ProtectHome=true and MemoryMax= or CPUQuota= for resource caps.
[Service]
ProtectSystem=strict
ReadWritePaths=/srv/reports /var/log/reports
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
MemoryMax=512MThe tool systemd-analyze security backup.service scores a unit's exposure and lists what remains open. Aim to close what the job does not need, not to reach a perfect score. The model of least privilege here is also the theme of the shell script security guide.
Logs with journald
Everything the service writes to standard output and standard error goes to the journal, tagged with the unit name, and survives as long as the journal is kept. No redirection is needed in the unit. Read it with journalctl, filter by unit and time, and follow it live.
journalctl -u backup.service --since today
journalctl -u backup.service -n 50 --no-pager
journalctl -u backup.service -f
journalctl -u backup.service -p err
systemctl show backup.service -p ExecMainStatus,ResultThe last command prints the exit status and result of the last run, such as Result=exit-code, which is what a monitoring check should read. Journal storage is persistent only if /var/log/journal exists or Storage=persistent is set; on some systems it is volatile and lost at reboot, so confirm before relying on it for post-mortems. The querying options are in journalctl(1).
Failure handling and alerts
A failed oneshot service enters the failed state and stays there until it runs again or is reset. Use OnFailure= to start another unit when it fails, such as a notifier service. The notifier can mail, post to a chat webhook or write to a monitoring endpoint, and receives the failed unit's name as an instance parameter.
# backup.service
[Unit]
OnFailure=notify@%n.service
# /etc/systemd/system/[email protected]
[Unit]
Description=Notify about failure of %i
[Service]
Type=oneshot
ExecStart=/usr/local/bin/notify.sh "%i failed on %H"The specifiers %n (the full unit name), %i (the instance) and %H (the hostname) are filled in by systemd. For jobs that can fail without failing the service, such as a script that logs errors but exits 0, a heartbeat is more reliable than failure triggers: have the job ping a monitoring service on success, and alert when the ping stops arriving. That covers the case where the job never ran at all, which no failure hook can see.
Cron or timer: how to choose
| Need | Cron | systemd timer |
|---|---|---|
| Runs on any Unix, containers, BSD | Yes | Only where systemd runs |
| Setup effort for one job | One line | Two files and an enable |
| Logs | Mail or your redirection | Journal by default |
| Catch-up after downtime | Needs anacron | Persistent=true |
| Prevent overlap | Needs flock | Built in for oneshot services |
| Dependencies (network, mounts) | Not expressible | After=, Requires= |
| Resource limits and sandboxing | None | cgroup and namespace directives |
| Randomized spread | Manual sleep | RandomizedDelaySec= |
| Run it now to test | Copy the command | systemctl start job.service |
| Status query | Parse logs | systemctl status, list-timers |
The decision usually reduces to two questions. Does the job have to run somewhere without systemd, such as a minimal container or macOS? Then cron, or the platform's equivalent. Otherwise, does it matter that it ran and what it said? Then a timer. For a throwaway job on a machine you will never touch again, one crontab line is faster than two unit files, and that is a legitimate choice.
User timers
An unprivileged user can have timers too, in ~/.config/systemd/user/, managed with systemctl --user. They run while the user has a session. To make them run without a login, as on a server, enable lingering with loginctl enable-linger username. Without lingering, a user timer stops when the last session ends, which is the usual reason it seems to "stop working overnight".
mkdir -p ~/.config/systemd/user
systemctl --user daemon-reload
systemctl --user enable --now notes-sync.timer
loginctl enable-linger "$USER"
systemctl --user list-timers
journalctl --user -u notes-sync.serviceUser units cannot bind to privileged resources and see the user's environment as systemd defines it, not as the login shell does. They are the right home for per-user jobs such as syncing notes or refreshing a cache, since they need no root and no entries in a system directory.
Time zones and daylight saving
Cron uses the system time zone, and so do systemd timers unless a time zone is written into the expression, which systemd supports, for example OnCalendar=*-*-* 09:00:00 Europe/Berlin. Daylight saving changes bite in two ways. When the clock jumps forward, a job scheduled in the skipped hour may not run. When the clock falls back, a job in the repeated hour may run twice. Vixie cron handles small shifts by running jobs that were skipped, and treats fixed-time jobs specially, but behavior varies between implementations.
The simple defence is to keep servers on UTC, schedule in UTC and convert in the report. Jobs that must follow a local business day, such as an 08:00 email to a Paris office, are the exception, and for them the time zone should be written into the timer so that it is visible in the unit file and survives a change to the machine's own zone. Always avoid scheduling at 02:00 to 03:00 local time in zones that change clocks, since that is the hour most likely to be skipped or doubled.
Testing a scheduled job
Test in layers so that a failure points at one cause. First run the script by hand with a stripped environment, as cron would: env -i HOME="$HOME" PATH=/usr/bin:/bin /usr/local/bin/backup.sh. Second, for a timer, run the service on demand: systemctl start backup.service and read the journal. Third, schedule the job a couple of minutes ahead with the real mechanism and watch it fire. Fourth, break it on purpose: make the script exit non-zero and confirm that the alert arrives.
env -i HOME="$HOME" PATH=/usr/bin:/bin /usr/local/bin/backup.sh; echo "exit=$?"
sudo systemctl start backup.service
journalctl -u backup.service -n 20 --no-pager
systemd-analyze verify /etc/systemd/system/backup.service /etc/systemd/system/backup.timersystemd-analyze verify checks unit files for syntax errors and missing referenced units without running anything. The stripped-environment run is the single most valuable test, since it reproduces the commonest cron failure, a missing PATH entry, in seconds. The techniques for finding out why a script behaves differently under a scheduler are the subject of the debugging guide.
Real job: nightly backup with rotation
This pairs the backup script from the Bash tutorial with a timer. The service runs as a dedicated user, the timer is persistent, and a failure notifies.
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup of /srv/data
OnFailure=notify@%n.service
[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/backup.sh -k 14 -d /var/backups/data /srv/data
ProtectSystem=strict
ReadWritePaths=/var/backups/data
PrivateTmp=true
TimeoutStartSec=2h
Nice=10# /etc/systemd/system/backup.timer
[Unit]
Description=Nightly backup
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=10min
[Install]
WantedBy=timers.targetsudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers backup.timerThe matching cron version is a single line, 30 2 * * * backup /usr/local/bin/backup.sh -k 14 -d /var/backups/data /srv/data >> /var/log/backup.log 2>&1 in /etc/cron.d/backup, plus a flock wrapper and a logrotate file to match the timer's behavior. The comparison shows where the extra lines of the timer go: they replace the pieces you would otherwise bolt on.
Real job: a health check every minute
Checking a service every minute is a monotonic pattern, and a good example of why OnUnitInactiveSec avoids overlap. The check fails loudly and does nothing otherwise.
#!/usr/bin/env bash
set -euo pipefail
url="${1:?usage: check.sh URL}"
if ! curl -fsS --max-time 10 -o /dev/null "$url"; then
logger -t healthcheck -p user.err "check failed for $url"
exit 1
fi# check.timer
[Timer]
OnBootSec=1min
OnUnitInactiveSec=1min
AccuracySec=1s
[Install]
WantedBy=timers.targetIf the endpoint hangs for the full ten second timeout, the next check starts a minute after that one ends, not a minute after it started, so a slow target cannot cause a pile of overlapping checks. In cron the same job is * * * * * flock -n /run/lock/check.lock check.sh URL, and the lock is what prevents the pile. Both work; the timer states the intent in the schedule itself.
Real job: clean old files
Housekeeping jobs are the most common cron entries and the most dangerous, because they delete. Make them print what they would do first, run with a narrow scope and log the result.
#!/usr/bin/env bash
set -euo pipefail
dir=/var/cache/app
days="${1:-30}"
[[ -d $dir ]] || { echo "missing $dir" >&2; exit 1; }
count=$(find "$dir" -xdev -type f -mtime +"$days" -print -delete | wc -l)
echo "removed $count files older than $days days from $dir"removed 128 files older than 30 days from /var/cache/app
The -xdev option keeps find on one filesystem, so a mounted volume under the directory is never touched. Test with -print only before you add -delete, and think about what happens if the variable were empty: the explicit directory check turns that into an error. For temporary files, systemd-tmpfiles with a tmpfiles.d rule is a purpose-built cleaner that needs no script. Consider it before writing your own.
Common mistakes and their fixes
- Relying on your interactive environment. Set PATH, use absolute paths, test with
env -i. - An unescaped percent in a crontab.
date +%Fbreaks. Escape it or call a script. - A dot in a file name under
/etc/cron.dorcron.daily. Debian ignores it. Use letters, digits, hyphens and underscores. - Wiping the crontab with
crontab -r. Keep it in version control. - Enabling the service instead of the timer. Enable
name.timer, and check it withlist-timers. - Forgetting
daemon-reload. systemd uses the old unit until you reload. - Putting shell syntax in
ExecStart. There is no shell. Call a script. - Overlapping runs. Add
flockin cron; use oneshot in systemd. - User timers that stop at logout. Run
loginctl enable-linger. - Assuming a silent job is a healthy job. Add a heartbeat, so that "never ran" is also an alert.
The last mistake is the expensive one. Many of the others produce a visible error once; this one produces nothing for weeks and then a missing backup on the worst day.
How the cron daemon works
Knowing the mechanism explains several odd behaviors. The daemon wakes once a minute, reads the crontabs it has loaded and starts every job whose fields match the current minute. It checks the modification times of the crontab files and reloads them when they change, which is why editing with crontab -e takes effect without a restart but a file replaced in a way that keeps the old timestamp may not. User crontabs live in a spool directory, /var/spool/cron/crontabs on Debian, and should only be edited through the crontab command, which validates syntax and notifies the daemon. Each job is started through the configured shell with a fresh process group, and the daemon does not wait for it; it neither tracks nor limits how many are running, which is why overlap protection is your job. Because the finest resolution is a minute, cron cannot run something every ten seconds, and the folk fix of a loop with sleep inside a once-a-minute job is fragile; use a timer with OnUnitActiveSec=10s or a long-running service instead.
Who may use cron
Access to crontab is controlled by two files. If /etc/cron.allow exists, only users listed in it may have a crontab. Otherwise, if /etc/cron.deny exists, everyone not listed may. If neither exists, behavior depends on the build: on Debian all users may, on some systems only root. Locking down the service accounts that should never schedule anything is a cheap hardening step. Jobs run with the owner's privileges, so a crontab of a compromised web-service account is a persistence mechanism an attacker will use. Audit the schedules on a server the same way you audit authorized SSH keys, with for u in $(cut -d: -f1 /etc/passwd); do crontab -l -u "$u" 2>/dev/null; done as root, plus a look at /etc/cron.* and the timers from systemctl list-timers --all.
One-off jobs with at
For work that should run once at a given time, at is simpler than a crontab entry you must remember to delete. It reads commands from standard input and runs them at the time you name, with the environment of the shell that queued it. atq lists the queue and atrm removes a job. batch runs a job when the system load is low. The systemd counterpart is a transient timer, created with systemd-run --on-calendar or --on-active, which also gives you logs.
echo '/usr/local/bin/reindex.sh' | at 23:30 tomorrow
atq
systemd-run --on-active=2h --unit=reindex-once /usr/local/bin/reindex.sh
systemctl list-timers reindex-once.timerA transient unit disappears once it has run and is not recreated at boot, so it suits a one-off but not a recurring task. If the machine reboots before the time arrives, a queued at job still runs because the queue is on disk, while a transient timer is lost.
React to events instead of the clock
Many jobs scheduled "every minute" are really waiting for something to happen, such as a file landing in a drop directory. Polling wastes work and adds up to a minute of latency. systemd has path units that watch the filesystem with inotify and start a service when a path changes. They pair with a service in the same way a timer does.
# /etc/systemd/system/ingest.path
[Path]
PathExistsGlob=/srv/drop/*.csv
MakeDirectory=true
[Install]
WantedBy=multi-user.targetThe matching ingest.service processes and removes the files. If it leaves them in place, the path unit triggers again immediately and the pair loops, so the service must consume what it was triggered by. Systemd also rate limits start requests, which stops a runaway loop from taking the host down but may leave the unit in a failed state to investigate. For events that are not files, such as a device appearing or a network coming up, there are udev rules and network dispatcher hooks; use the clock only for things that are genuinely about time.
Retries and backoff
A scheduled job that fails on a transient error, such as a network blip, will be retried by the next scheduled run, which may be a day away. Decide whether that is acceptable. For a daily report, probably; for a job that other work depends on, add retries. A Type=oneshot service cannot use Restart=on-failure in older systemd versions, though systemd 244 and later allow Restart=on-failure with oneshot, so on current systems you can write the retry policy into the unit. The portable alternative is a retry loop in the script with exponential backoff and an upper bound.
retry() {
local tries="$1" delay=2; shift
local n
for (( n = 1; n <= tries; n++ )); do
"$@" && return 0
echo "attempt $n/$tries failed: $*" >&2
(( n < tries )) && sleep "$delay"
delay=$(( delay * 2 ))
done
return 1
}
retry 4 curl -fsS --max-time 20 -o /tmp/feed.xml https://example.org/feed.xmlBound the total time. Four tries with delays of 2, 4 and 8 seconds is a bounded 14 seconds plus request time, whereas unlimited retries turn a short outage into a job that runs for days and blocks its own next run. Make the job idempotent first, since a retry after a partial success will repeat the part that worked.
Idempotence and catch-up safety
A persistent timer and a retry both mean a job may run late, twice or out of the usual order. Design for that. The job should not assume it is running at 02:30, so a report for "yesterday" should compute yesterday from the date and not from the hour. It should be safe to run twice, so writing a result to a temporary name and renaming it into place replaces any earlier output atomically. And it should take its inputs from a named period, passed as an argument or derived from the date, so a manual rerun for a past day is a command-line change rather than an edit.
#!/usr/bin/env bash
set -euo pipefail
day="${1:-$(date -d yesterday +%F)}"
out="/srv/reports/daily-$day.csv"
tmp=$(mktemp "$out.XXXXXX")
trap 'rm -f -- "$tmp"' EXIT
./build-report.sh "$day" > "$tmp"
mv -- "$tmp" "$out"Rerunning for a specific date, ./daily.sh 2026-11-01, rebuilds just that file. The temporary file is created next to the destination so the final mv is a rename on one filesystem, which cannot leave a half-written report visible to a reader.
Environment files and secrets
Scheduled jobs often need credentials. Do not put them in a crontab line, where they show in the process list and in backups of the file, or in a unit file that is world-readable. With systemd, point EnvironmentFile= at a file mode 0600 owned by root, or better use LoadCredential=, which hands the service a secret as a file in a private directory that other users cannot read. With cron, source a protected file from the script and have the script check its permissions.
[Service]
LoadCredential=api-token:/etc/secrets/api-token
ExecStart=/usr/local/bin/push.shtoken=$(cat "${CREDENTIALS_DIRECTORY}/api-token")The credentials directory is created per run and removed afterward, and its path comes through the CREDENTIALS_DIRECTORY variable. This feature arrived in systemd 247, so on older distributions fall back to an environment file with tight permissions. The wider discipline of handling secrets in scripts is in the shell script security guide.
Monitoring with a heartbeat
Failure hooks tell you that a job failed. They cannot tell you that it never started, because the host was down, the timer was disabled, or someone deleted the crontab. A dead-man's switch covers that: the job pings a monitoring URL when it succeeds, and the monitor raises an alert if the ping does not arrive within the expected window. Many hosted services and self-hosted tools offer this; the client side is a single curl.
./backup.sh && curl -fsS --max-time 10 --retry 3 https://hc.example.org/ping/0a1b2c3d > /dev/nullThe && means the ping fires only after a successful run. Give the monitor a grace period slightly longer than the job's normal duration plus its random delay. Check the monitor itself occasionally by disabling a job on purpose and confirming that the alert arrives, because an alarm you have never heard is an alarm you cannot trust. Alongside the heartbeat, record how long the job takes; a backup that used to take ten minutes and now takes two hours is failing in slow motion.
Move a crontab to timers step by step
Migrating is mechanical and is best done one job at a time, with both versions running side by side for a day to compare. For each crontab line, take the five fields and rewrite them as an OnCalendar= value, check it with systemd-analyze calendar, move the command into ExecStart= of a oneshot service, translate any redirections into journal logging, and replace any flock wrapper with the service's own single-instance behavior. Then disable the cron line by commenting it out, not deleting it, until the timer has run successfully twice.
| Crontab fields | OnCalendar |
|---|---|
30 2 * * * | *-*-* 02:30:00 |
*/15 * * * * | *:0/15 |
0 9 * * 1-5 | Mon..Fri *-*-* 09:00:00 |
0 4 1 * * | *-*-01 04:00:00 |
0 */6 * * * | *-*-* 00/6:00:00 |
@weekly | Mon *-*-* 00:00:00 |
Each row in the right column was parsed by systemd-analyze calendar on the test host and printed the normalized form shown. Remember the day-of-month and weekday difference: a cron line that restricted both ran on either, while a timer requires both. If you relied on the OR behavior, write two OnCalendar= lines, since a timer accepts several.
Managing schedules on many hosts
When the same job runs on a fleet, hand-edited crontabs drift. Put the unit files or crontab fragments in version control and install them with whatever you use to configure hosts, so the schedule has an author, a diff and a rollback. Use drop-in directories for per-host overrides, such as /etc/systemd/system/backup.timer.d/override.conf, so the base unit stays identical and only the local difference is stored. Vary the start times with RandomizedDelaySec and not with a hand-picked minute per host, which is easy to forget.
sudo systemctl edit backup.timer
systemctl cat backup.timer
systemctl list-timers --all --no-pager | head -20The systemctl cat output shows the base unit and every drop-in in order, which is the way to see what a host actually runs. Keep a short inventory of what runs where, because the question "what jobs does this server have?" has answers in several places: user crontabs, /etc/cron.d, the periodic directories, anacron, system and user timers, and at queues.
Scheduled jobs in containers and Kubernetes
Containers change where the scheduler lives. A container normally runs one process, so a cron daemon inside the application image is awkward: it needs its own logging, it inherits no environment from the container runtime unless you copy it, and a stopped container stops the schedule. Three patterns work better. Run the schedule on the host and start a short-lived container each time with docker run --rm. Use a dedicated scheduler container that triggers jobs in others. Or use the orchestrator's own scheduler, which in Kubernetes is the CronJob.
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-report
spec:
schedule: "30 2 * * *"
timeZone: "Etc/UTC"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 600
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 3600
template:
spec:
restartPolicy: OnFailure
containers:
- name: report
image: registry.example.org/report:1.4.2
command: ["/usr/local/bin/report.sh"]
The settings map directly to the concerns in this guide. concurrencyPolicy: Forbid is the overlap lock. startingDeadlineSeconds decides how late a missed run may still start, which is Kubernetes' version of catch-up. activeDeadlineSeconds is the timeout. backoffLimit and restartPolicy are the retry policy. The timeZone field, stable since Kubernetes 1.27, removes the old problem of the schedule following the controller's zone. Pin the image by a version or digest so that a rerun is the same code. The crontab syntax in the schedule field is the standard five fields from earlier in this guide.
Templated and instanced timers
When several jobs share a shape and differ by a parameter, systemd templates avoid copies of the same files. A unit named [email protected] is a template; [email protected] instantiates it with %i set to home. A timer can be a template too, so enabling [email protected] and [email protected] gives two schedules from one pair of files.
# /etc/systemd/system/[email protected]
[Unit]
Description=Backup of %i
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh /srv/%i /var/backups/%i
# /etc/systemd/system/[email protected]
[Unit]
Description=Nightly backup of %i
[Timer]
OnCalendar=*-*-* 02:30
RandomizedDelaySec=15min
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl enable --now [email protected] [email protected]
systemctl list-timers 'backup@*'
sudo systemctl edit [email protected]The last command opens a drop-in for one instance, so srv can have a different time without touching the template. Specifiers beyond %i include %u for the user, %H for the hostname and %t for the runtime directory, and they are expanded in most directives, which keeps the units free of hard-coded names.
Conditions: run only when it makes sense
A job often should run only if something is true: a file exists, a mount is present, the machine is on mains power, the network is up. systemd has Condition and Assert directives for this. A failed condition skips the unit quietly; a failed assertion marks it failed, which is the better choice when the absence of the thing is a problem worth an alert.
[Unit]
ConditionPathIsMountPoint=/mnt/backup
ConditionACPower=true
AssertPathExists=/etc/backup/key
ConditionHost=!staging*
ConditionFileNotEmpty=/srv/data/manifest
The first line prevents a backup from writing into an empty mount point on the root disk, a mistake that fills the system partition while the real disk sits unmounted. Conditions are evaluated when the unit starts, so a timer that fires while the condition is false skips that run and tries again at the next trigger. Cron has nothing like this; the equivalent is a guard at the top of the script, which works, but is invisible to systemctl status.
Sending mail and notifications from scheduled jobs
Cron's default is to mail output to the job's owner, which needs a working mail transfer agent. On servers without one, install a minimal sender such as msmtp or postfix in satellite mode, pointing at an authenticated relay, and test it before relying on it. Send a test message from the account that runs the job, and read the mail log if it does not arrive.
# /etc/msmtprc
defaults
auth on
tls on
tls_trust_file /etc/ssl/certs/ca-certificates.crt
account relay
host smtp.example.org
port 587
from [email protected]
user [email protected]
passwordeval "cat /etc/msmtp.pass"
account default : relay
printf 'Subject: test\n\nhello\n' | msmtp -a relay [email protected]
echo "backup failed on $(hostname)" | mail -s "backup failure" [email protected]
curl -fsS -X POST -H 'Content-Type: application/json' \
-d "{\"text\":\"backup failed on $(hostname)\"}" "$WEBHOOK_URL"Chat webhooks are often more reliable than mail and are noticed sooner; keep the URL in a protected file because anyone who has it can post. Whichever channel you use, send only what a person needs to act on: the host, the job, the time and the last lines of the error. A flood of success messages trains people to ignore the channel, so alert on failure and on silence, and keep success quiet.
Exposing job results to monitoring
Beyond a heartbeat, a job can publish what happened as metrics so that dashboards and alerts follow it. The Prometheus node exporter has a textfile collector that reads .prom files from a directory. A job writes its last success time, duration and a status code there, and an alert fires when the success time gets too old. Write the file atomically so the collector never reads a half-written one.
start=$(date +%s)
if ./backup.sh; then status=0; else status=1; fi
end=$(date +%s)
out=/var/lib/node_exporter/textfile/backup.prom
tmp=$(mktemp "$out.XXXXXX")
cat > "$tmp" <<EOF
backup_last_run_timestamp_seconds $end
backup_duration_seconds $(( end - start ))
backup_last_status $status
EOF
chmod 644 "$tmp"; mv -- "$tmp" "$out"An alert rule on time() - backup_last_run_timestamp_seconds > 90000 catches a job that stopped running, which a failure hook cannot. The same data tells you when the duration is creeping up. If you have no metrics stack, a file with the timestamp in a known place and a one-line check from your monitoring tool is the same idea at a smaller scale.
Security of scheduled jobs
A scheduler is a place where code runs without a person watching, often with privilege, so it deserves the same review as a network service. Make sure only the intended people can edit the schedules and the scripts they call. A root cron job that runs a script writable by a regular user is a privilege escalation, and so is a script that sources a file from a world-writable directory or runs a command found through a PATH that includes one. Check ownership of every file in the chain, from the crontab or unit through the script to anything it executes or reads.
namei -l /usr/local/bin/backup.sh
find /etc/cron* /etc/systemd/system -xdev -writable -not -user root 2>/dev/null
sudo systemd-analyze security backup.service | tail -3
crontab -l -u www-dataThe namei -l output lists the permissions of each path component, so a writable directory anywhere above the script stands out. Run each job as the least-privileged account that can do its work, apply the sandboxing options from the hardening section, and review the full list of schedules on a host as part of routine audits. Attackers who get a foothold often add a cron job or a timer for persistence, and a known inventory is what lets you spot the extra one.
Testing time-dependent behavior
Schedules are hard to test because they depend on the clock. There are three practical techniques. First, test the schedule expression itself with systemd-analyze calendar --iterations=10, and look at the next ten times, including across a month end and a daylight saving change. Second, test the job by running it on demand with the date as an argument, so you can feed it the dates that matter. Third, for code that reads the clock, run it under faketime, which fools a process into seeing a chosen time without changing the system clock.
systemd-analyze calendar --iterations=4 '*-*-01 04:00:00'
./daily.sh 2026-02-28
./daily.sh 2026-03-01
faketime '2026-03-29 01:59:50' ./job.shTry the awkward dates on purpose: the last day of February, the 31st for a monthly job, the weekday at a month boundary, and the hours on either side of a daylight saving change if you do not run in UTC. A job that reports on "yesterday" and runs at midnight must do the right thing on the first of the month, when yesterday is in the previous one; computing the date with date -d yesterday handles it, and adding a day by arithmetic on the day number does not.
A runbook for a job that stopped
When someone reports that a scheduled job has not run, a fixed sequence finds the cause fastest. Confirm that the schedule exists and is enabled: crontab -l and the cron directories for cron, systemctl list-timers --all and systemctl is-enabled for timers. Check when it last ran and what it said. Check that the daemon was running at the time, with systemctl status cron or the journal for the boot. Look for a condition that skipped it, such as a failed mount, a lock held by an earlier hung run, or a full disk that stopped it from writing. Run it by hand as the right user in a clean environment. Only then read the script.
systemctl list-timers --all | grep -E 'NEXT|backup'
systemctl status backup.service --no-pager | head -12
journalctl -u backup.service -n 30 --no-pager
journalctl -u cron --since yesterday --no-pager | grep -i backup
df -h /var/backups; ls -l /run/lock/ | grep backupWrite the runbook down next to the job, with the commands for your setup, and fill in the host names and paths. The person who reads it at three in the morning will not remember the details, and the commands you already trust are the ones to put first.
Where this does not apply
Containers. A typical container has no cron daemon and no systemd. Either run the schedule outside, with the orchestrator's scheduler such as a Kubernetes CronJob, or add a dedicated scheduler process, and avoid running cron next to your application as a second process in the same container.
Distributed jobs. Cron and timers run on one machine. If the job must run once per cluster, two hosts with the same crontab will both run it. Use leader election, a queue or a managed scheduler.
Sub-second and event-driven work. Neither is a real-time system. For file arrival, use inotify or a .path unit. For high-frequency work, run a long-lived process.
Complex workflows. When jobs depend on one another's output with retries and backfills, a workflow engine models it better than a pile of timers.
macOS and non-systemd Linux. macOS uses launchd, Alpine and Void often use OpenRC or runit with cron. The scheduling concepts here carry over, the syntax does not.
Where to go next
Scheduling pushes you toward scripts that are safe to run unattended: the Bash scripting tutorial covers locks, traps and exit codes, the shell script security guide covers least privilege, and the testing guide covers proving a script works before the scheduler runs it at 02:30. For connecting out to other machines from a scheduled job, see the SSH guide.
Primary sources used here: crontab(5), cron(8), flock(1), systemd.timer(5), systemd.time(7), systemd.service(5), systemd.exec(5), systemd-analyze(1), journalctl(1) and the POSIX crontab specification.


