You will schedule a recurring server task with both cron and a systemd timer, adding logging and missed-run catch-up. By the end you will have observable automation that survives reboots and tells you when it fails.

Learning Objectives

  • Write a logged cron entry with a sane environment.
  • Create an equivalent systemd timer with Persistent catch-up.
  • Alert on failure rather than failing silently.
  • Time: ~2 hours · Difficulty: Beginner · Prereqs: a systemd-based server.

Architecture Overview

Environment Setup

You will need: a systemd server and a script to schedule (for example the restic backup from the backup lab).

Before you begin: use absolute paths in scheduled jobs; cron's environment is minimal.

Prerequisites

  • Linux with cron and systemd; root or sudo for system timers.
  • A backup or maintenance script to schedule (the restic lab pairs well).
  • A log directory writable by the job (e.g. /var/log).
  • Mail or webhook alerting for failures (see CLI email lab).

Step-by-Step Execution

01
Add a logged cron entry

Create the script the schedule will run. It uses absolute paths, logs with UTC timestamps, and exits nonzero on failure (via set -euo pipefail) so problems surface instead of passing silently.

CREATE THE BACKUP SCRIPT

sudo tee /usr/local/bin/backup.sh > /dev/null <<'EOF' #!/bin/bash set -euo pipefail # Scheduled jobs have no interactive shell: restic needs its password from the # environment. Exporting it here covers both the cron entry and the systemd unit. export RESTIC_PASSWORD_FILE=/root/.restic-pass echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') starting backup" /usr/bin/restic -r /backup/repo backup /etc /srv --tag daily echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') backup finished" EOF sudo chmod +x /usr/local/bin/backup.sh
[ROOT REQUIRED] Creates an executable backup script with absolute paths.

Install the cron entry into root's crontab. The job writes to /var/log/backup.log and reads system paths, and a plain user crontab cannot write to /var/log, so sudo crontab is required here; for a non-root job, point the log at a user-writable path instead. The grep -v filter makes the install idempotent: re-running it never duplicates the line.

INSTALL THE CRON ENTRY (IDEMPOTENT)

( sudo crontab -l 2>/dev/null | grep -v -F '/usr/local/bin/backup.sh'; echo "30 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1" ) | sudo crontab -

Expected output:

(no output on success; verify with: sudo crontab -l)
30 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

What this teaches: Preserving the existing crontab while appending avoids wiping other jobs; filtering the old line first keeps re-runs idempotent; redirecting both streams to a log makes failures visible.

02
Create a systemd timer with catch-up

Persistent=true runs a missed job after downtime, which plain cron will not do. A timer never runs a script directly: it activates a same-named .service unit, so create backup.service first. flock -n in ExecStart makes an overrunning backup exit instead of stacking a second copy on top of it.

CREATE THE BACKUP.SERVICE UNIT

sudo tee /etc/systemd/system/backup.service > /dev/null <<'EOF' [Unit] Description=Daily backup job Wants=network-online.target After=network-online.target [Service] Type=oneshot ExecStart=/usr/bin/flock -n /run/backup.lock /usr/local/bin/backup.sh EOF sudo systemd-analyze verify /etc/systemd/system/backup.service && echo "service OK"
[ROOT REQUIRED] The unit the timer activates; flock -n prevents overlapping runs. The restic password comes from the export inside the script, so both schedulers work; the systemd-native alternative is Environment=RESTIC_PASSWORD_FILE=/root/.restic-pass under [Service].

Now create the timer that activates it:

printf '[Timer]\nOnCalendar=*-*-* 02:30:00\nPersistent=true\n[Install]\nWantedBy=timers.target\n' | sudo tee /etc/systemd/system/backup.timer
[ROOT REQUIRED] Pairs with a backup.service unit.

Expected output:

[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.target

What this teaches: OnCalendar expresses the schedule; Persistent=true replays missed runs after downtime, the key reliability advantage over cron.

03
Enable and inspect

Expected output:

# systemctl daemon-reload && systemctl enable --now backup.timer && systemctl list-timers backup.timer
NEXT LEFT UNIT ACTIVATES 2026-06-18 02:30 UTC 16h backup.timer backup.service

What this teaches: daemon-reload picks up the new timer; enable --now activates it; list-timers confirms the next trigger time.

How It Works

On servers, scheduling is infrastructure: backups, rotations, cert renewals, and health checks all run unattended. cron remains the lingua franca because every Unix has it and the five-field syntax is universal, but its failure modes (silent failures, no catch-up, minimal environment) are exactly what cause 3 AM pages.

The operational discipline matters more than the tool: every scheduled job logs with timestamps, alerts on failure (never on success), is idempotent so overlapping runs are safe, and holds a lock (flock) so a slow run does not stack. These four habits turn scheduling from a hope into a system.

systemd timers add the missing reliability pieces: journal logging by default, dependency ordering (run after the database is up), calendar and monotonic triggers, and Persistent catch-up. For server workloads these advantages compound, which is why new automation should default to timers.

Progress So Far

Testing & Validation

sudo systemctl start backup.service && journalctl -u backup.service -n 5 --no-pager

You should see the job run and log output. If the timer also appears in list-timers with a future run, scheduling is working.

Troubleshooting

SymptomLikely causeFix
Job runs but produces nothingOutput lost: no redirection and no mail configuredRedirect to a log file and check it; add MAILTO as a backup
Timer fired during a deploy windowSchedule collides with maintenanceUse RandomizedDelaySec to spread load and document blackout windows
Overlapping runs corrupt stateJob outlasts its intervalWrap ExecStart with flock -n or implement locking in the script
Cron PATH breaks the scriptMinimal environment strikes againAbsolute paths everywhere; set PATH explicitly at the top of the crontab
Missed runs after weekend downtimecron never catches upUse a systemd timer with Persistent=true for anything that must not be skipped
Timezone confusion in schedulesCron uses system local timeKeep servers on UTC and express schedules in UTC to avoid DST surprises
Troubleshooting
  • Cron job runs in shell but not scheduled: cron has a minimal PATH; use absolute paths and set variables.
  • Timer never fires: confirm it is enabled and the OnCalendar syntax with systemd-analyze calendar.
  • Missed runs: set Persistent=true so the job catches up after downtime.

Extension Ideas

  • Add an OnFailure= unit that sends a notification.
  • Schedule the restic backups from the backup lab.
  • Randomize start with RandomizedDelaySec to avoid thundering herds.

Key Results

  • Scheduled a task via both cron and a systemd timer.
  • Captured all job output to logs for observability.
  • Enabled missed-run catch-up with Persistent timers.
  • Validated the schedule with list-timers and a manual run.

Key Takeaways

  • Log every scheduled job; alert on failure, not success.
  • Make jobs idempotent and locked against overlap.
  • cron is universal; systemd timers add logging, deps, and catch-up.
  • Persistent=true replays missed runs after downtime.
  • UTC schedules avoid daylight-saving surprises.
  • Set MAILTO so job output reaches your inbox; silent failures stop being silent.
  • Use full paths in jobs because the cron PATH is minimal.

Practice Challenges

Challenge 1: Observable backup

Goal: schedule the restic backup from the backup lab via a systemd timer with logging, failure alerting, and flock protection.

Hint: the timer calls a wrapper script that handles all four.

Challenge 2: Cron audit

Goal: inventory every cron entry and timer on a host, document what each does and who owns it, and flag any without logging.

Hint: /etc/cron*, /var/spool/cron, systemctl list-timers.