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 sudo privileges.

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.
Featurecronsystemd timer
LogsMail or manual redirectionjournald, per unit
Last run and exit codeNot recordedsystemctl status, list-timers
Runs missed while powered offLostCaught up with Persistent=true
Avoiding simultaneous startsManual sleep $RANDOM hacksRandomizedDelaySec=
Overlapping runsPossibleA running service is not started twice
CPU, memory, I/O limits and sandboxingNoAny systemd.exec / systemd.resource-control option
SetupOne lineTwo 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
  • OnCalendar sets a wall-clock schedule, here 02:30 every day.
  • RandomizedDelaySec adds a random delay of up to 15 minutes, useful when many servers share the same schedule.
  • Persistent=true stores 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:

ExpressionMeaning
hourly, daily, weekly, monthlyShorthands; daily means *-*-* 00:00:00
*:0/15Every 15 minutes
*-*-* 02:30:00Every day at 02:30
Sun *-*-* 03:00Sundays at 03:00
*-*-01 04:00First day of every month at 04:00
*-*-* 08,20:00Every 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:

crontabTimer setting
*/15 * * * *OnCalendar=*:0/15
0 * * * *OnCalendar=hourly
30 2 * * 1-5OnCalendar=Mon..Fri *-*-* 02:30:00
0 3 1 * *OnCalendar=*-*-01 03:00:00
@rebootOnBootSec=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 .timer and not only the .service, and that systemctl list-timers shows a NEXT time. If NEXT is empty, the OnCalendar expression is invalid; test it with systemd-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 with journalctl -u name.service -n 50. Sandboxing options such as ProtectSystem=strict also block writes outside ReadWritePaths.
  • Changes are ignored: run sudo systemctl daemon-reload after every edit. systemd-analyze verify /etc/systemd/system/etc-backup.timer reports 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.