devops2025-07-08·8·9/348

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

FeatureCronsystemd Timers
LoggingManual (redirect to file)Automatic via journalctl
Dependency managementNoneBuilt-in (After, Requires)
Missed runs catch-upNoYes (with OnCalendar)
Calendar expressionsLimitedRich syntax
Resource limitsNoCpuQuota, MemoryMax
Boot timingDelay neededOnBootSec 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>&1

Problems 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=journal

Step 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.target

Step 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.service

Step 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=60

Understanding 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.timer

Database 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.target

Health 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.target

Useful 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.service

Migration 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

  1. Use Persistent=true for any task that must not be missed, even across reboots.
  2. RandomizedDelaySec prevents multiple timers from hitting external APIs simultaneously.
  3. journalctl integration is the biggest practical advantage over cron for debugging.
  4. Start with OnBootSec for services that need to run shortly after boot.
  5. Use systemd-analyze calendar to 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.