Replacing Cron with systemd Timers on Linux
How to migrate scheduled tasks from cron to systemd timers, with practical examples and comparison of both scheduling approaches.
Replacing Cron with systemd Timers on Linux
Cron has been the standard for task scheduling on Linux for decades. But systemd timers offer better logging, dependency management, and more flexible scheduling. I migrated several data pipeline tasks from cron to systemd timers on my Lisbon trading server, and the improved observability alone was worth the switch.
Environment
- OS: Ubuntu 22.04 LTS
- systemd version: 249
- Use case: Scheduled data collection and processing tasks
$ systemd --version
systemd 249 (249.11-0ubuntu3.12)Cron vs systemd Timers
| Feature | Cron | systemd Timers |
|---|---|---|
| Logging | Manual (redirect to file) | Automatic via journalctl |
| Dependency management | None | Built-in (After, Requires) |
| Missed runs catch-up | No | Yes (with OnCalendar) |
| Calendar expressions | Limited | Rich syntax |
| Resource limits | No | CpuQuota, MemoryMax |
| Boot timing | Delay needed | OnBootSec built-in |
Problem: Cron Limitations
# Old crontab approach
$ crontab -l
0 */6 * * * /opt/scripts/collect-data.sh >> /var/log/data-collect.log 2>&1
30 2 * * 0 /opt/scripts/backup-db.sh >> /var/log/backup.log 2>&1
*/5 * * * * /opt/scripts/health-check.sh >> /var/log/health.log 2>&1Problems with this setup:
- Log files grow unbounded without logrotate
- No way to see when the last run started/finished
- If the server was down during a scheduled time, the job is simply missed
- No dependency ordering with other services
Solution: Migrate to systemd Timers
Step 1: Create the Service Unit
# /etc/systemd/system/data-collector.service
[Unit]
Description=Data Collection Service
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=oneshot
User=datacollector
Group=datacollector
WorkingDirectory=/opt/scripts
ExecStart=/opt/scripts/collect-data.sh
StandardOutput=journal
StandardError=journalStep 2: Create the Timer Unit
# /etc/systemd/system/data-collector.timer
[Unit]
Description=Run data collection every 6 hours
[Timer]
OnCalendar=*-*-* 00/6:00:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.targetStep 3: Enable and Start
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now data-collector.timer
# Verify timer is active
$ sudo systemctl list-timers --all
NEXT LEFT LAST PASSED UNIT ACTIVATES
Sat 2025-07-08 18:00:00 WET 5h left Sat 2025-07-08 12:00:00 WET 1h ago data-collector.timer data-collector.service
Sun 2025-07-09 02:30:00 WET 13h left Sun 2025-07-02 02:30:00 WET 6d ago db-backup.timer db-backup.serviceStep 4: Check Logs with journalctl
# See all runs of the service
$ sudo journalctl -u data-collector.service --since today
-- Logs begin at Sat 2025-07-08 00:00:00 WET --
Jul 08 12:00:01 server systemd[1]: Starting Data Collection Service...
Jul 08 12:00:15 server collect-data.sh[1234]: Collecting market data from 3 sources
Jul 08 12:00:45 server collect-data.sh[1234]: Processed 14523 records
Jul 08 12:00:46 server systemd[1]: data-collector.service: Succeeded.
Jul 08 12:00:46 server systemd[1]: Finished Data Collection Service.Common Calendar Expressions
# Every 5 minutes
OnCalendar=*-*-* *:00/5:00
# Every hour
OnCalendar=*-*-* *:00:00
# Every day at 2:30 AM
OnCalendar=*-*-* 02:30:00
# Every Sunday at 3 AM
OnCalendar=Sun *-*-* 03:00:00
# Every 6 hours
OnCalendar=*-*-* 00/6:00:00
# First day of every month at midnight
OnCalendar=*-*-01 00:00:00
# Mon-Fri at 9 AM
OnCalendar=Mon..Fri *-*-* 09:00:00
# Every 15 minutes with a random delay (prevents thundering herd)
OnCalendar=*-*-* *:00/15:00
RandomizedDelaySec=60Understanding Persistent=true
The Persistent=true option is crucial. It means if the system was off when a timer should have fired, it will run as soon as the system comes back up:
# Without Persistent: missed run is lost
# With Persistent: missed run catches up on next boot
# Verify catch-up behavior
$ sudo systemctl list-timers
NEXT LEFT LAST PASSED UNIT
Sun 2025-07-09 00:00:00 WET 16h left Sat 2025-07-08 06:00:00 WET 6h ago data-collector.timerDatabase Backup Example
# /etc/systemd/system/db-backup.service
[Unit]
Description=PostgreSQL Database Backup
After=postgresql.service
Requires=postgresql.service
[Service]
Type=oneshot
User=postgres
ExecStart=/opt/scripts/backup-db.sh
StandardOutput=journal
StandardError=journal# /etc/systemd/system/db-backup.timer
[Unit]
Description=Weekly database backup (Sunday 2:30 AM)
[Timer]
OnCalendar=Sun *-*-* 02:30:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.targetHealth Check with Boot Delay
# /etc/systemd/system/health-check.service
[Unit]
Description=System Health Check
[Service]
Type=oneshot
ExecStart=/opt/scripts/health-check.sh
StandardOutput=journal# /etc/systemd/system/health-check.timer
[Unit]
Description=Run health check every 5 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
Persistent=true
[Install]
WantedBy=timers.targetUseful Timer Commands
# List all timers
$ sudo systemctl list-timers
# Show timer details
$ sudo systemctl status data-collector.timer
# Manually trigger the service (bypass timer)
$ sudo systemctl start data-collector.service
# View timer properties
$ systemctl show data-collector.timer -p OnCalendar -p NextElapseUSecRealtime
# Reset failed state
$ sudo systemctl reset-failed data-collector.serviceMigration Script
#!/bin/bash
# migrate-cron-to-timer.sh
CRON_ENTRY="$1"
SERVICE_NAME="$2"
echo "Converting: $CRON_ENTRY"
echo "Service: $SERVICE_NAME"
# Parse cron timing (simplified - real parser would be more complex)
MINUTE=$(echo "$CRON_ENTRY" | awk '{print $1}')
HOUR=$(echo "$CRON_ENTRY" | awk '{print $2}')
DAY=$(echo "$CRON_ENTRY" | awk '{print $3}')
MONTH=$(echo "$CRON_ENTRY" | awk '{print $4}')
DOW=$(echo "$CRON_ENTRY" | awk '{print $5}')
COMMAND=$(echo "$CRON_ENTRY" | awk '{print $6, $7, $8}')
echo "Schedule: $MINUTE $HOUR $DAY $MONTH $DOW"
echo "Command: $COMMAND"Lessons Learned
- Use
Persistent=truefor any task that must not be missed, even across reboots. RandomizedDelaySecprevents multiple timers from hitting external APIs simultaneously.- journalctl integration is the biggest practical advantage over cron for debugging.
- Start with OnBootSec for services that need to run shortly after boot.
- Use
systemd-analyze calendarto test calendar expressions before deploying:
$ systemd-analyze calendar '*-*-* 00/6:00:00'
Original: *-*-* 00/6:00:00
Normalized: *-*-* 00:00:00
Next run: Sat 2025-07-08 18:00:00 WET (5h 23min ago)This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.