If you run Linux servers long enough, you eventually outgrow copy-pasted crontabs.
systemd timers give you better observability, dependency control, and service isolation than classic cron—while still being simple enough for daily ops.
This guide is hands-on. You’ll migrate one real cron job end-to-end and add production-safe behavior.
Why move scheduled jobs to systemd timers?
A .timer triggers a .service unit. That split is the key advantage:
- You can run the job on-demand (
systemctl start your-job.service) without waiting for the schedule. - Logs are centralized in journald (
journalctl -u your-job.service). - Jobs can use normal systemd controls (dependencies, resource limits, restart behavior, sandboxing).
- You can make missed runs execute after reboot with
Persistent=true.
For recurring automation on modern Linux distros, this is often a cleaner default than cron.
What we’ll migrate
Current cron line (daily backup at 02:30):
30 2 * * * /usr/local/bin/backup-db.sh
We’ll replace it with:
- A service unit that runs the script.
- A timer unit for schedule + reliability features.
- Validation and troubleshooting commands.
1) Create the script (with safe bash settings)
sudo install -d -m 0755 /usr/local/bin
sudo tee /usr/local/bin/backup-db.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR=/var/backups/myapp
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$BACKUP_DIR"
# Example payload; replace with your real backup command
echo "backup generated at ${STAMP}" >"$BACKUP_DIR/backup-${STAMP}.txt"
# Keep only last 14 backups
ls -1t "$BACKUP_DIR"/backup-*.txt | tail -n +15 | xargs -r rm -f
EOF
sudo chmod 0755 /usr/local/bin/backup-db.sh
2) Create the service unit
File: /etc/systemd/system/backup-db.service
[Unit]
Description=Backup application data
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-db.sh
User=root
Group=root
Apply and test:
sudo systemctl daemon-reload
sudo systemctl start backup-db.service
sudo systemctl status backup-db.service --no-pager
journalctl -u backup-db.service -n 50 --no-pager
3) Create the timer unit
File: /etc/systemd/system/backup-db.timer
[Unit]
Description=Run backup-db.service every day at 02:30 UTC
[Timer]
OnCalendar=*-*-* 02:30:00 UTC
Persistent=true
RandomizedDelaySec=10m
AccuracySec=1m
Unit=backup-db.service
[Install]
WantedBy=timers.target
Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now backup-db.timer
systemctl list-timers --all | grep backup-db
Why these options matter
-
OnCalendar=: wall-clock scheduling (cron-like). -
Persistent=true: if host was down at 02:30, run once on next boot. -
RandomizedDelaySec=10m: spreads starts to avoid thundering herd across many hosts. -
AccuracySec=1m: allows some coalescing for efficiency (default is already 1 minute).
If you want tighter timing, reduce AccuracySec.
4) Validate schedule expressions before production
Use systemd-analyze to verify calendar syntax and next runs:
systemd-analyze calendar '*-*-* 02:30:00 UTC' --iterations=5
Example output includes normalized expression and upcoming trigger times.
5) Day-2 operations (the commands you’ll actually use)
# Is timer enabled and when does it run next?
systemctl status backup-db.timer --no-pager
systemctl list-timers --all | grep backup-db
# Force an immediate run
sudo systemctl start backup-db.service
# See recent logs
journalctl -u backup-db.service --since '24 hours ago' --no-pager
# Stop schedule temporarily
sudo systemctl disable --now backup-db.timer
Common migration pattern (cron → systemd)
-
@daily→OnCalendar=daily -
@weekly→OnCalendar=weekly -
*/15 * * * *→OnCalendar=*:0/15 -
@reboot→ usuallyOnBootSec=(monotonic timer)
For login-scoped user jobs, use systemctl --user timers (and enable lingering when required).
Final checklist for production
- [ ] Script is idempotent and uses
set -euo pipefail. - [ ] Service runs successfully when started manually.
- [ ] Timer has
Persistent=trueif catch-up behavior is required. - [ ] You validated schedule with
systemd-analyze calendar. - [ ] You have log/alert visibility via journald (or forwarding stack).
References
- Debian man page:
systemd.timer(5)https://manpages.debian.org/testing/systemd/systemd.timer.5.en.html - Debian man page:
systemd.unit(5)https://manpages.debian.org/testing/systemd/systemd.unit.5.en.html - ArchWiki: systemd/Timers (practical examples and cron mapping) https://wiki.archlinux.org/title/Systemd/Timers
- Forem/DEV API docs (for publishing workflows) https://developers.forem.com/api/v0
If you want, I can publish a follow-up with hardened unit settings (ProtectSystem, PrivateTmp, NoNewPrivileges) and a reusable timer template for multi-job fleets.
Top comments (0)