IntermediateServer & Linux Basicssystemdcronlinuxubuntuautomationsysadmin
How To Schedule Tasks with systemd Timers Instead of Cron on Ubuntu 22.04
Tricknowtech Team 12 min read
Goal
By the end of this tutorial, a script will run on an automatic schedule as a systemd .service/.timer pair instead of a crontab entry, with its output queryable through journalctl, its next run confirmed via systemctl list-timers, and a missed run automatically caught up on the next boot.
Prerequisites
One Ubuntu 22.04 LTS server, set up with a non-root user that has sudo privileges (see an initial server setup guide if you haven't done this yet)
Comfort editing files over SSH with a command-line text editor such as nano or vim
Basic familiarity with the systemctl and journalctl commands is helpful but not required
Let an AI agent do this for you
Copy a ready-made prompt for an AI coding assistant with terminal access to your server (Claude Code, Cursor, or similar) — it can carry out the steps below for you. Review what it plans to run before it executes anything.
cron has scheduled recurring jobs on Unix-like systems for decades, and it still works fine for simple cases. But it has real gaps: a cron job's output is only captured if you redirect it yourself or if mail delivery happens to be configured on the box, a crontab entry has no way to say "wait until the network is up" or "wait until this other job finished," and if the machine is powered off at the scheduled minute, cron just skips that run with no record of it having been missed.
Ubuntu has used systemd as its init system since 15.04, and systemd ships with a scheduling mechanism built out of two paired unit types: a .timer unit that defines when to run something, and a .service unit that defines what to run. Because both are ordinary systemd units, you get the journal's structured logging for free, you can declare dependencies on other units, and a timer can be marked to catch up on a run it missed. This tutorial builds one such pair from scratch — a small backup script, the service unit that runs it, and the timer unit that schedules it — and shows how to enable, inspect, test, and adjust the result.
Step 1 — Create the Script to Schedule
Start with the actual task you want automated. systemd runs services without a login shell or the environment variables an interactive session provides, so the script must use full paths and not rely on anything set in a personal .bashrc. The example below tars /etc into a timestamped archive, a common enough maintenance task to schedule.
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR=/var/backups/etc-snapshots
TIMESTAMP=$(date +%F-%H%M%S)
DEST="${BACKUP_DIR}/etc-${TIMESTAMP}.tar.gz"
mkdir -p "${BACKUP_DIR}"
tar --create --gzip --file "${DEST}" /etc
echo "Backup written to ${DEST}"
bash
sudo chmod 750 /usr/local/bin/backup-etc.sh
Run it once by hand with sudo /usr/local/bin/backup-etc.sh to confirm it works before wiring any scheduling around it — a script is much easier to debug outside of systemd than inside it.
Step 2 — Create the Service Unit
A .service unit tells systemd what to execute. For a script that runs once and exits, rather than a long-running daemon, set Type=oneshot; systemd waits for the process to exit and then considers that run complete, which matches how cron treats a job. Create the file directly under /etc/systemd/system/, using the same base name you'll give the timer in the next step.
bash
sudo nano /etc/systemd/system/backup-etc.service
ini
[Unit]
Description=Back up /etc to a timestamped tar.gz archive
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-etc.sh
Tricknowtech VPS Hosting
Dedicated KVM resources and full root access — deployed in under 60 seconds, no ticket required.
No [Install] section is needed here — a service meant to be triggered by a timer isn't enabled directly, so there's nothing for systemctl enable to hook into on this unit.
“By default a systemd service runs as root, the same as a job in root's crontab. If the script doesn't need root privileges, add User= and Group= directives under [Service] to run it as an unprivileged account instead.”
Step 3 — Create the Matching Timer Unit
The timer unit carries the same base filename as the service it triggers — backup-etc.timer activates backup-etc.service automatically, with no need to reference it explicitly. Its [Timer] section holds the schedule; OnCalendar= accepts systemd's calendar event syntax, covered in more detail in Step 7. The [Install] section is what lets systemctl enable register the timer to start at boot.
“If you want a timer and service pair with different base names, add Unit=other-name.service under [Timer] to point at it explicitly. Otherwise keep the filenames matching — it's the default systemd relies on.”
Step 4 — Reload systemd and Enable the Timer, Not the Service
systemd only reads unit files from disk when told to, so reload its configuration first. Then enable and start the .timer unit — this is the step people most often get backwards. Because backup-etc.service was written without an [Install] section in Step 2, running systemctl enable backup-etc.service directly would not schedule anything at all — it would fail outright with an error telling you the unit has no installation config, since there's no WantedBy= line for systemctl to act on. (A one-shot service that does carry its own [Install] section would enable successfully, but that would only make it run once at boot, not on a recurring schedule.) It's the timer that supplies the recurring schedule, so it's the timer — not the service — that needs to be enabled and started.
enable creates the symlink under timers.target.wants/ so the timer comes back after a reboot; --now also starts it immediately in the current session, so you don't have to reboot to confirm it's running.
Step 5 — Verify the Timer with systemctl list-timers
list-timers lists every timer systemd knows about, active or not, along with when each last ran and when it's due next — the systemd equivalent of crontab -l, but aggregated across every timer on the system rather than one user's file.
bash
sudo systemctl list-timers --all
Look for backup-etc.timer in the UNIT column; the NEXT/LEFT columns show the next scheduled run and how long until then, LAST/PASSED show the previous run, and ACTIVATES confirms which service it will trigger. systemctl status gives the same information for a single timer, with more detail.
bash
sudo systemctl status backup-etc.timer
Step 6 — Trigger a Manual Run and Check the Logs
Don't wait until 3:30 AM to find out whether the job actually works. Starting the .service unit directly runs it immediately, independent of the timer's schedule, without disturbing when the timer will next fire on its own.
bash
sudo systemctl start backup-etc.service
sudo systemctl status backup-etc.service
Every line the script writes to stdout or stderr is captured automatically by the journal and tagged with the unit name — no MAILTO= setting or manual >> logfile 2>&1 redirection required, unlike a plain cron job.
Step 7 — Fine-Tune the Schedule with OnCalendar Expressions
OnCalendar= understands both shorthand keywords and an explicit DayOfWeek Year-Month-Day Hour:Minute:Second form, documented in full in man systemd.time. A few common patterns:
OnCalendar=daily — once a day at midnight, equivalent to *-*-* 00:00:00
OnCalendar=weekly — once a week, Monday at midnight
OnCalendar=*-*-* 03:30:00 — every day at 03:30, as used above
OnCalendar=Mon,Wed,Fri 09:00:00 — 09:00 on specific weekdays only
OnCalendar=*-*-01 04:00:00 — 04:00 on the first day of every month
Rather than guessing whether an expression means what you think, ask systemd directly — systemd-analyze calendar parses an expression and prints its normalized form and the next few times it would fire, without you having to save it to a unit file first.
bash
systemd-analyze calendar "Mon,Wed,Fri 09:00:00"
After editing OnCalendar= in an existing timer unit, re-run sudo systemctl daemon-reload followed by sudo systemctl restart backup-etc.timer so the new schedule takes effect.
Step 8 — Catch Up on Missed Runs with Persistent=true
cron only fires a job at the instant its schedule matches; if the machine is off, asleep, or otherwise unavailable at that instant, the run is simply skipped with no record of it. Persistent=true, set on the timer in Step 3, changes that behavior: systemd records the last time the timer fired, and if it finds that a scheduled run was missed while the system was down, it runs the service once shortly after the system is next up — rather than waiting for the next full interval to come around.
This matters most for machines that aren't always running — a workstation shut down overnight, or a cloud instance stopped and started on demand. systemctl status backup-etc.timer shows a Trigger: line with the next scheduled time; the timestamp systemd uses to detect a missed run is kept under /var/lib/systemd/timers/, in a file named after the timer unit.
Step 9 — Add Dependency Ordering on Other Units
A crontab entry only knows the wall-clock time; it has no way to express "not before the network is reachable" or "only after this filesystem is mounted." Because a .service unit is an ordinary systemd unit, it can declare the same After=/Wants=/Requires= directives any other unit uses. Add them to the [Unit] section of backup-etc.service:
ini
[Unit]
Description=Back up /etc to a timestamped tar.gz archive
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-etc.sh
Wants= is a soft dependency — the backup still runs even if network-online.target fails to come up — while Requires= is a hard dependency that would block this service if the target failed. Reload systemd after editing:
bash
sudo systemctl daemon-reload
Step 10 — Comparing systemd Timers to cron
With the pair in place and tested, the practical differences from a crontab entry are:
Logging: every run's output lands in the journal automatically and is queryable with journalctl -u <name>.service, with no MAILTO= setting or manual output redirection to configure.
Dependency ordering: After=/Wants=/Requires= let a job wait on network availability, a mounted filesystem, or another service — something a crontab line has no syntax for.
Catch-up runs: Persistent=true reruns a schedule the system missed while powered off, instead of silently skipping it until the next interval.
Introspection: systemctl list-timers and systemctl status show what's scheduled, when it last ran, and when it runs next, without parsing crontab -l output or digging through a mail spool.
Conclusion
This tutorial built a backup-etc.service/backup-etc.timer pair under /etc/systemd/system/, enabled and started the timer rather than the service, confirmed the schedule with systemctl list-timers, and inspected a run's output with journalctl -u backup-etc.service. It also covered adjusting the schedule with OnCalendar= expressions, verifying one with systemd-analyze calendar, letting a missed run catch up automatically via Persistent=true, and ordering the job against other units with After=/Wants=. The same pattern — a oneshot service plus a calendar timer — applies to any recurring task currently living in a crontab.