systemd timers schedule tasks the way cron does, but each job runs as a regular systemd service. That gives you logs in the journal, a recorded exit status, resource limits, sandboxing and the ability to catch up on runs missed while the server was off. In this tutorial you will replace a cron job with a systemd timer on Ubuntu 24.04 that backs up /etc every night, then learn the calendar syntax, how to convert existing crontab entries and how to run timers as a regular user.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS (systemd 255), for example a CubePath VPS. Debian 12 and Rocky Linux 9 work the same way.
- A non-root user with
sudoprivileges.
How timers compare to cron
A timer is always made of two unit files with the same base name:
name.service: what to run.name.timer: when to run it.
| Feature | cron | systemd timer |
|---|---|---|
| Logs | Mail or manual redirection | journald, per unit |
| Last run and exit code | Not recorded | systemctl status, list-timers |
| Runs missed while powered off | Lost | Caught up with Persistent=true |
| Avoiding simultaneous starts | Manual sleep $RANDOM hacks | RandomizedDelaySec= |
| Overlapping runs | Possible | A running service is not started twice |
| CPU, memory, I/O limits and sandboxing | No | Any systemd.exec / systemd.resource-control option |
| Setup | One line | Two small files |
Cron is still fine for trivial one-liners. Timers are worth it for anything you need to monitor or that must not overlap.
Step 1 - Writing the backup script
The timer will run a script that archives /etc into /var/backups/etc and keeps the last 14 days. Create it:
sudo nano /usr/local/bin/etc-backup
#!/usr/bin/env bash
set -euo pipefail
dest="/var/backups/etc"
keep_days=14
stamp="$(date +%Y%m%d-%H%M%S)"
mkdir -p "$dest"
tar -czf "$dest/etc-$stamp.tar.gz" -C / etc
find "$dest" -name 'etc-*.tar.gz' -type f -mtime +"$keep_days" -delete
echo "Backup written to $dest/etc-$stamp.tar.gz"
Make it executable:
sudo chmod 0755 /usr/local/bin/etc-backup
There is no need to redirect output to a log file: anything the script prints goes to the journal.
Step 2 - Creating the service unit
The service describes how to run the script. Type=oneshot tells systemd the process runs to completion instead of staying in the background. Create the unit:
sudo nano /etc/systemd/system/etc-backup.service
[Unit]
Description=Back up /etc to /var/backups/etc
[Service]
Type=oneshot
ExecStart=/usr/local/bin/etc-backup
Nice=10
IOSchedulingClass=idle
ProtectSystem=strict
ReadWritePaths=/var/backups
PrivateTmp=yes
NoNewPrivileges=yes
Nice and IOSchedulingClass=idle keep the backup from slowing down other workloads. ProtectSystem=strict mounts the whole file system read-only for this service, except the paths listed in ReadWritePaths, so a bug in the script cannot write anywhere else.
Reload systemd and run the service once by hand to verify it works before scheduling it:
sudo systemctl daemon-reload
sudo systemctl start etc-backup.service
sudo systemctl status etc-backup.service
○ etc-backup.service - Back up /etc to /var/backups/etc
Loaded: loaded (/etc/systemd/system/etc-backup.service; static)
Active: inactive (dead)
...
Sep 25 10:12:04 server etc-backup[4211]: Backup written to /var/backups/etc/etc-20260925-101204.tar.gz
Sep 25 10:12:04 server systemd[1]: Finished etc-backup.service - Back up /etc to /var/backups/etc.
inactive (dead) is the expected state for a oneshot service that finished successfully. A failure would show failed with the exit code.
Step 3 - Creating the timer unit
The timer has the same base name, so it activates etc-backup.service automatically. Create it:
sudo nano /etc/systemd/system/etc-backup.timer
[Unit]
Description=Run etc-backup every night
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=15min
Persistent=true
[Install]
WantedBy=timers.target
OnCalendarsets a wall-clock schedule, here 02:30 every day.RandomizedDelaySecadds a random delay of up to 15 minutes, useful when many servers share the same schedule.Persistent=truestores the last run time on disk. If the server was off at 02:30, the job runs as soon as it boots.
Enable and start the timer (you enable the .timer, not the .service):
sudo systemctl daemon-reload
sudo systemctl enable --now etc-backup.timer
Confirm it is scheduled:
systemctl list-timers etc-backup.timer
NEXT LEFT LAST PASSED UNIT ACTIVATES
Sat 2026-09-26 02:38:51 UTC 16h left - - etc-backup.timer etc-backup.service
1 timers listed.
The next run time includes the random delay. Run systemctl list-timers --all to see every timer on the system, including the ones Ubuntu ships for apt, logrotate and fstrim.
Step 4 - Testing calendar expressions
OnCalendar uses the format DayOfWeek Year-Month-Day Hour:Minute:Second, where any field can be *, a list (Mon,Fri), a range (Mon..Fri) or a repetition (0/15 means every 15 starting at 0). Never guess: systemd-analyze calendar shows how systemd normalizes an expression and when it will next fire.
systemd-analyze calendar --iterations=3 'Mon..Fri *-*-* 09:00'
Original form: Mon..Fri *-*-* 09:00
Normalized form: Mon..Fri *-*-* 09:00:00
Next elapse: Mon 2026-09-28 09:00:00 UTC
From now: 2 days left
Iter. #2: Tue 2026-09-29 09:00:00 UTC
Iter. #3: Wed 2026-09-30 09:00:00 UTC
Common expressions:
| Expression | Meaning |
|---|---|
hourly, daily, weekly, monthly | Shorthands; daily means *-*-* 00:00:00 |
*:0/15 | Every 15 minutes |
*-*-* 02:30:00 | Every day at 02:30 |
Sun *-*-* 03:00 | Sundays at 03:00 |
*-*-01 04:00 | First day of every month at 04:00 |
*-*-* 08,20:00 | Every day at 08:00 and 20:00 |
Times are in the server's local time zone unless you append one, for example *-*-* 02:30 Europe/Madrid.
Step 5 - Converting cron entries
Most crontab lines translate directly. Put the command in a service unit as in Step 2 and use the matching timer setting:
| crontab | Timer setting |
|---|---|
*/15 * * * * | OnCalendar=*:0/15 |
0 * * * * | OnCalendar=hourly |
30 2 * * 1-5 | OnCalendar=Mon..Fri *-*-* 02:30:00 |
0 3 1 * * | OnCalendar=*-*-01 03:00:00 |
@reboot | OnBootSec=1min |
Timers also support monotonic schedules relative to an event rather than the clock. For example, to run a job 5 minutes after boot and then every hour after the last run finished:
[Timer]
OnBootSec=5min
OnUnitActiveSec=1h
This is often better than "every hour on the hour" for jobs that should not run on all servers at the same moment.
For a one-off or quick experiment, systemd-run creates a transient timer without writing any file:
sudo systemd-run --on-active=2min --unit=one-shot-backup /usr/local/bin/etc-backup
Step 6 - Checking logs and failures
Because each run is a service, all output and the exit status are in the journal. View today's runs:
journalctl -u etc-backup.service --since today
To be alerted about failures, list failed units (a good check to add to your monitoring):
systemctl --failed
After you edit a unit file, always reload systemd; for timer changes, restart the timer so the new schedule takes effect:
sudo systemctl daemon-reload
sudo systemctl restart etc-backup.timer
Step 7 - Running timers as a regular user
Users can schedule their own jobs without sudo by placing units in ~/.config/systemd/user/ and using systemctl --user. Create the directory and a service that cleans the user's download folder:
mkdir -p ~/.config/systemd/user
nano ~/.config/systemd/user/clean-downloads.service
[Unit]
Description=Delete downloads older than 30 days
[Service]
Type=oneshot
ExecStart=/usr/bin/find %h/Downloads -type f -mtime +30 -delete
%h expands to the user's home directory. Create the timer:
nano ~/.config/systemd/user/clean-downloads.timer
[Unit]
Description=Weekly downloads cleanup
[Timer]
OnCalendar=weekly
Persistent=true
[Install]
WantedBy=timers.target
Enable it:
systemctl --user daemon-reload
systemctl --user enable --now clean-downloads.timer
User timers only run while the user has an active session. To keep them running after you log out, enable lingering for your account, replacing your_user:
sudo loginctl enable-linger your_user
Troubleshooting
- The timer never fires: check that you enabled the
.timerand not only the.service, and thatsystemctl list-timersshows aNEXTtime. IfNEXTis empty, theOnCalendarexpression is invalid; test it withsystemd-analyze calendar. - The script works in a shell but fails under systemd: services run with a minimal environment and
PATH. Use absolute paths, and check the error withjournalctl -u name.service -n 50. Sandboxing options such asProtectSystem=strictalso block writes outsideReadWritePaths. - Changes are ignored: run
sudo systemctl daemon-reloadafter every edit.systemd-analyze verify /etc/systemd/system/etc-backup.timerreports syntax errors in unit files.
Conclusion
You replaced a cron job with a service and timer pair that logs to the journal, runs sandboxed, catches up on missed runs and spreads its start time. The same pattern works for any scheduled task. Next, add an OnFailure= unit that sends you a notification when a job fails, apply MemoryMax= or CPUQuota= to heavy jobs, and move your remaining crontab entries using the conversion table above.
