How to Replace Cron Jobs with systemd Timers: A Practical Guide

Why systemd Timers Are the New Cron (and How to Use Them)

If you’ve administered a Linux server for more than five minutes, you’ve probably edited a crontab. It’s one of those rituals — like configuring your .bashrc or setting up SSH keys. Cron has been around since 1975, and it works. But in 2026, there’s a better way: systemd timers.

systemd timers are what cron would look like if it were designed today. They offer logging, dependency management, retry logic, and calendar scheduling that actually makes sense. And they’re already on your system — every major Linux distribution ships with systemd.

In this tutorial, we’ll replace a typical cron job with a systemd timer, step by step. By the end, you’ll wonder why you didn’t switch sooner.

Prerequisites

  • A Linux system with systemd (Ubuntu 16.04+, Debian 9+, CentOS 7+, Fedora, Arch, etc.)
  • Basic familiarity with the command line
  • A script you want to run on a schedule (we’ll create one)
  • sudo access

Step 1: Create Your Script

Let’s say you want to back up a directory every day at 2 AM. First, create the script:

sudo mkdir -p /opt/scripts
sudo nano /opt/scripts/daily-backup.sh

Add this content:

#!/bin/bash
# Daily backup script
BACKUP_SRC="/home/noodly/projects"
BACKUP_DEST="/backups/projects"
DATE=$(date +%Y-%m-%d)
LOG="/var/log/daily-backup.log"

echo "[$(date)] Starting backup..." >> "$LOG"

mkdir -p "$BACKUP_DEST"
tar czf "$BACKUP_DEST/backup-$DATE.tar.gz" "$BACKUP_SRC" 2>> "$LOG"

if [ $? -eq 0 ]; then
    echo "[$(date)] Backup completed successfully." >> "$LOG"
else
    echo "[$(date)] Backup FAILED!" >> "$LOG"
fi

# Keep only the last 7 backups
ls -t "$BACKUP_DEST"/backup-*.tar.gz | tail -n +8 | xargs rm -f

Make it executable:

sudo chmod +x /opt/scripts/daily-backup.sh

Step 2: Create the systemd Service Unit

A systemd timer needs a service unit to tell it what to run. Think of the service as the “what” and the timer as the “when.”

sudo nano /etc/systemd/system/daily-backup.service

Add this content:

[Unit]
Description=Daily backup of projects directory
After=network.target

[Service]
Type=oneshot
ExecStart=/opt/scripts/daily-backup.sh
User=noodly
Group=noodly

# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/backups/projects /var/log

Let’s break down what each part does:

  • Type=oneshot — This tells systemd the script runs once and exits. Use this for backup scripts, cleanup tasks, and anything that isn’t a long-running daemon.
  • ExecStart= — The command to run. Same thing you’d put in crontab.
  • User/Group — Run as a specific user instead of root. This is cleaner than cron’s per-user crontabs.
  • The security hardening lines (NoNewPrivileges, ProtectSystem, etc.) — These are bonus features cron can’t give you. They sandbox your script so even if it’s compromised, the damage is limited.

Step 3: Create the systemd Timer Unit

Now the fun part — the timer that controls when the service runs:

sudo nano /etc/systemd/system/daily-backup.timer

Add this content:

[Unit]
Description=Run daily backup at 2:00 AM

[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target

Here’s what makes this powerful:

  • OnCalendar= — This is cron’s schedule syntax, but readable. *-*-* 02:00:00 means “every day at 2:00 AM.” You can also do Mon,Fri 09:00:00 for specific days, or *:0/30 for every 30 minutes.
  • Persistent=true — If the server was off at 2 AM, the backup runs immediately when it boots. Cron would just skip it.
  • RandomizedDelaySec=300 — Adds a random delay of up to 5 minutes. This is great if you have multiple timers that would otherwise all fire at exactly the same instant and spike your CPU.

Step 4: Enable and Start the Timer

Reload systemd to pick up the new units, then enable and start the timer:

sudo systemctl daemon-reload
sudo systemctl enable --now daily-backup.timer

That’s it. The --now flag starts the timer immediately, so you don’t have to wait until 2 AM to verify it works.

Step 5: Verify Everything Is Working

Check the timer status:

systemctl status daily-backup.timer

You should see something like:

● daily-backup.timer - Run daily backup at 2:00 AM
     Loaded: loaded (/etc/systemd/system/daily-backup.timer; enabled; preset: enabled)
     Active: active (waiting) since Sat 2026-06-21 12:00:00 UTC
     Trigger: Sun 2026-06-22 02:00:00 UTC; 14h left

List all active timers to see when they’re scheduled to fire:

systemctl list-timers

This gives you a beautiful overview of every timer on the system — something cron simply cannot do.

Check the logs for your service:

journalctl -u daily-backup.service

This is one of the biggest advantages over cron. Every run is logged automatically. No more redirecting output to a file and hoping for the best.

Common OnCalendar Patterns

Here’s a quick reference for schedule expressions:

Pattern Meaning
*-*-* 02:00:00 Every day at 2 AM
Mon,Fri 09:00:00 Monday and Friday at 9 AM
Mon..Fri *-*-* 08:00:00 Weekdays at 8 AM
*:0/30:00 Every 30 minutes
*-*-01 00:00:00 First day of every month at midnight
Sat *-*-* 03:00:00 Every Saturday at 3 AM

Common Pitfalls and Troubleshooting

1. Forgetting daemon-reload

After editing any .service or .timer file, you must run sudo systemctl daemon-reload. systemd won’t pick up your changes until you do. This trips up everyone at least once.

2. Timer shows “n/a” for next trigger

If systemctl list-timers shows n/a for the next trigger, your OnCalendar syntax is probably wrong. Test it with:

systemd-analyze calendar "*-*-* 02:00:00"

This will parse your expression and show the next few trigger times.

3. Service runs but produces no output

By default, systemd captures all stdout/stderr. Use journalctl -u your-service.service to see it. If you’re debugging, add StandardOutput=journal to the [Service] section.

4. Timer doesn’t fire after reboot

Make sure you used Persistent=true. Without it, if the system was off when the timer was supposed to fire, that execution is simply lost. With Persistent=true, systemd catches up on the next boot.

5. Permission denied errors

Remember that the service runs as the User= you specified. Make sure that user has permission to read the source files and write to the destination. Test by running the script manually as that user:

sudo -u noodly /opt/scripts/daily-backup.sh

Bonus: Adding Retry Logic

Here’s something cron absolutely cannot do natively — automatic retries. Add these lines to your [Service] section:

[Service]
...
Restart=on-failure
RestartSec=60

Now if your backup script fails (maybe a network mount was temporarily unavailable), systemd will automatically retry after 60 seconds. You can also add StartLimitIntervalSec and StartLimitBurst to cap the number of retries.

Wrapping Up

systemd timers give you everything cron does, plus:

  • Built-in logging via journald — no more 2>>&1 >> /var/log/whatever.log
  • Persistent timers that catch up after downtime
  • Security sandboxing with ProtectSystem, NoNewPrivileges, and friends
  • Dependency management — your timer can wait for network, mounts, or other services
  • Retry logic with automatic restart on failure
  • Centralized managementsystemctl list-timers shows everything at a glance

Cron isn’t going anywhere — it’s simple, universal, and perfect for quick one-liners. But for anything you care about running reliably on a systemd-based Linux system, timers are the modern choice.

Your future self — the one reading clean logs at 3 AM instead of debugging why a cron job silently failed — will thank you.

Leave a Reply

Your email address will not be published. Required fields are marked *