How to Create a systemd Timer (Cron Alternative) on Fedora

Replace cron jobs on Fedora with systemd timers by creating a paired `.service` and `.timer` unit file, then enabling the timer with `systemctl`.

You set up a backup script that works perfectly when you run it manually

You want the script to run every night at 3 AM. You remember cron from your old Linux days, but Fedora pushes systemd timers. You create a .timer file, enable it, and wait. The next morning, the backup didn't run. journalctl shows nothing. You are staring at a silent failure while your data sits unprotected.

This happens because systemd timers separate the schedule from the action. The timer fires. The service fails. The timer stays active. Unless you check the service logs, you see no evidence of the failure. The timer did its job. The service did not.

The timer and service are two separate units

A systemd timer is not a script. It is a trigger. The timer unit tells systemd when to pull the trigger. The service unit tells systemd what happens when the trigger pulls. This separation is the source of most confusion.

Think of the timer as a calendar reminder and the service as the task on the checklist. The reminder pops up at the right time. If the task is broken, the reminder still pops up. You have to check the task result separately.

Every timer requires two files:

  • A .service unit that defines the command, user, and dependencies.
  • A .timer unit that defines the schedule and persistence behavior.

If the names do not match exactly, systemd will not link them. The timer will fire, but nothing will happen.

Create the service unit

Place system-wide units in /etc/systemd/system/. This directory overrides package defaults and survives package updates. Never edit files in /usr/lib/systemd/system/. Those files belong to packages. An update will overwrite your changes.

Create the service unit first. This defines what runs.

[Unit]
Description=Daily database backup
# After ensures the network is fully up before the script tries to reach the remote server.
# network.target only means NetworkManager is running, not that you have an IP.
After=network-online.target
Wants=network-online.target

[Service]
# oneshot tells systemd the service runs a command and exits.
# systemd waits for the process to finish before marking the unit inactive.
Type=oneshot
# Run as a specific user to avoid root privilege escalation risks.
User=backupuser
# The actual command to execute.
ExecStart=/usr/local/bin/backup.sh
# WorkingDirectory sets the CWD for the script.
# Relative paths in the script resolve against this directory.
WorkingDirectory=/home/backupuser

Test the service manually before enabling the timer. sudo systemctl start daily-backup.service reveals syntax errors instantly.

Create the timer unit

Create the timer unit with the same base name as the service. This defines when the service runs.

[Unit]
Description=Trigger daily database backup

[Timer]
# Calendar expression for 02:00 every day.
OnCalendar=*-*-* 02:00:00
# If the system was off at 02:00, run the service immediately after boot.
# This prevents missed backups due to sleep or power loss.
Persistent=true
# AccuracySec reduces jitter. systemd may delay the trigger slightly to batch wakeups.
# Setting this to 1s ensures the timer fires within one second of the target time.
# Default is 1 minute, which is fine for most tasks.
AccuracySec=1s

[Install]
# WantedBy ties the timer to the timers.target so it starts on boot.
WantedBy=timers.target

Reload the daemon after every edit. systemd caches unit files in memory. Changes on disk do nothing until you reload.

Enable and verify the timer

Enable the timer to start it on boot and activate it immediately.

sudo systemctl daemon-reload
# daemon-reload forces systemd to re-read unit files from disk.
# This is required after creating or editing any unit file.
sudo systemctl enable --now daily-backup.timer
# enable creates the symlink so the timer starts on boot.
# --now starts the timer immediately without a reboot.

Check the timer status to confirm the next trigger.

systemctl list-timers daily-backup.timer
# list-timers shows the next scheduled trigger and the last trigger time.
# If the timer is not listed, it is not active.

Check the logs immediately after the first run. A silent failure now becomes a data loss event later.

Test calendar expressions

Calendar expressions can be tricky. A typo in the expression means the timer never fires. Validate expressions before deploying them.

systemd-analyze calendar "*-*-* 02:00:00"
# systemd-analyze calendar validates the expression and shows the next trigger.
# This catches syntax errors before you deploy the timer.

Common expressions include:

  • hourly: Every hour at the top of the hour.
  • daily: Every day at midnight.
  • weekly: Every Monday at midnight.
  • *:0/15: Every 15 minutes.
  • Mon *-*-* 09:00:00: Every Monday at 9 AM.

Use systemd-analyze calendar to verify complex expressions. The output shows the next trigger time. If the time looks wrong, the expression is wrong.

User-level timers

User timers run under your user context. They do not require root privileges. They are useful for personal tasks like syncing dotfiles or running local backups.

User units live in ~/.config/systemd/user/. The systemd user instance manages these units. It starts when you log in and stops when you log out.

mkdir -p ~/.config/systemd/user
# Create the directory for user-level units.
# User units do not require root privileges.
systemctl --user enable --now mytask.timer
# --user targets the user instance of systemd.
# This runs the timer under your current user session.

User timers cannot access system resources. They cannot run as root. They cannot access paths outside your home directory unless the filesystem permissions allow it. If your script needs sudo or accesses /var/lib, a user timer will fail with permission denied. Use system timers for privileged tasks.

Common pitfalls

Network timing failures

The most common failure is network timing. If your script needs the internet, After=network.target is not enough. network.target means the network manager is running, not that you have an IP address. Use After=network-online.target and add Wants=network-online.target in the service unit. Without Wants, the dependency is weak. The service will wait, but if the network never comes online, the service might not run.

Persistent behavior

Persistent=true runs the service immediately after boot if a trigger was missed. It does not run the service for every missed trigger. If the machine was off for three days, the service runs once when it turns on. It does not run three times. If you need to catch up on multiple missed runs, you must handle that logic inside the script.

AccuracySec and power management

systemd batches timer triggers to reduce disk wakeups and save battery. The default AccuracySec is one minute. This means a timer set for 02:00 might fire anywhere between 02:00 and 02:01. This is intentional. Reducing AccuracySec increases power consumption. Only reduce it if your task requires precise timing. For backups, the default is fine.

RemainAfterExit for interval timers

For Type=oneshot services, the unit becomes inactive as soon as the process exits. This is normal. The timer continues to fire based on the calendar. If you use OnUnitActiveSec instead of OnCalendar, you must add RemainAfterExit=true to the service unit. Without it, the service becomes inactive immediately, and the interval timer resets on every trigger. Most backup scripts use OnCalendar, so this rarely applies, but it breaks interval timers silently.

SELinux denials

If the service fails with a permission error and the logs show nothing helpful, check SELinux. SELinux denials appear in journalctl -t setroubleshoot. Read the one-line summary. It tells you exactly what was denied and often suggests a fix. Do not disable SELinux. Fix the policy or the file context.

Read the error message. journalctl -u shows the exact reason the service failed. Guessing wastes hours.

Decision matrix

Use systemd timers when you need dependency ordering, such as waiting for the network or a database to be ready before running a script. Use systemd timers when you want Persistent=true to catch up on missed runs after a reboot. Use systemd timers when you need fine-grained calendar expressions or interval-based triggers with accuracy control. Use cron when you are running a simple command that does not depend on other services and you prefer a single-line configuration. Use systemd path units when you need to trigger a service based on file changes rather than a time schedule. Stay with cron if you are managing a legacy system where migrating to timers introduces more risk than benefit.

Where to go next