Systemd Timer Example: A Working Unit Pair to Copy
A working systemd timer and service pair you can copy, with OnCalendar schedules, how to test them, user timers, and how to replace a cron job.

A systemd timer is two unit files: a .service that does the work and a .timer with the same base name that says when to run it. Here's the smallest pair that works. Save both in /etc/systemd/system/, then run sudo systemctl enable --now backup.timer.
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup nightly
[Timer]
OnCalendar=*-*-* 03:00:00
[Install]
WantedBy=timers.target
That runs backup.sh at 03:00 every day. The rest of this article covers how the pairing works, more OnCalendar schedules, user timers, testing, and swapping out a cron job.
How a systemd timer and service fit together#
The systemd.timer(5) manual says it plainly: for each timer file, a matching unit file must exist, describing the unit to activate when the timer elapses. By default that's a service with the same name minus the suffix. So foo.timer activates foo.service.
If the names differ, point the timer at the right unit with Unit= in the [Timer] section:
[Timer]
OnCalendar=daily
Unit=cleanup-job.service
A few things the manual spells out that matter in practice:
- The timer gets a
Before=dependency on the service it activates, added automatically. - Timers pick up default dependencies on
sysinit.targetandtimers.target, unless you setDefaultDependencies=no. Only timers involved with early boot or late shutdown should do that. - A timer with at least one
OnCalendar=line also waits fortime-set.targetandtime-sync.target, so it doesn't start before the clock is correct.
I'd use Type=oneshot for job-style services, since the unit is "done" when the script exits. That's a convention from systemd.service(5), not something the timer page requires.
Systemd timer OnCalendar examples#
OnCalendar= takes a calendar expression. The full grammar lives in systemd.time(7). These are the forms I reach for most. Treat the list as a starting point and confirm each one with the tool in the next section.
| Goal | OnCalendar value |
|---|---|
| Every day at 03:00 | *-*-* 03:00:00 |
| Daily (shorthand) | daily |
| Hourly | hourly |
| Weekly | weekly |
| Mondays at 09:30 | Mon *-*-* 09:30:00 |
| Weekdays at 08:00 | Mon..Fri *-*-* 08:00:00 |
| First day of each month at midnight | *-*-01 00:00:00 |
| Every 15 minutes | *:0/15 |
You can set OnCalendar= more than once in one timer, and each line adds another trigger. That's handy for "weekdays at 08:00 and Saturdays at 10:00" without writing one clumsy expression.
[Timer]
OnCalendar=Mon..Fri *-*-* 08:00:00
OnCalendar=Sat *-*-* 10:00:00
For a daily job, the daily shorthand is the shortest thing that works. An explicit time like *-*-* 03:00:00 is better when you care about when it runs, such as keeping a backup away from business hours.
Test a timer before you trust it#
Don't wait until 03:00 to find out your expression was wrong. systemd-analyze (listed in the manual's cross-references) can parse a calendar expression and show when it next fires:
systemd-analyze calendar "Mon..Fri *-*-* 08:00:00"
It prints the normalized form and the next elapse time. Run it on every expression you write.
Then check the live state:
systemctl list-timers
systemctl status backup.timer
systemctl status backup.service
list-timers shows when each timer will fire next and when it last did. To run the job right now without touching the schedule, start the service directly:
sudo systemctl start backup.service
journalctl -u backup.service
That checks the script, its paths, and its permissions separately from the timing, which is usually where the first bug is.
Here's a small script that builds the unit pair from a name, command, and schedule, and checks the naming rule from the manual. It only prints text, so it's safe to run anywhere.
def make_units(name, command, calendar):
service = (
"[Unit]\n"
f"Description={name} job\n\n"
"[Service]\n"
"Type=oneshot\n"
f"ExecStart={command}\n"
)
timer = (
"[Unit]\n"
f"Description=Schedule for {name}\n\n"
"[Timer]\n"
f"OnCalendar={calendar}\n\n"
"[Install]\n"
"WantedBy=timers.target\n"
)
return {f"{name}.service": service, f"{name}.timer": timer}
units = make_units("backup", "/usr/local/bin/backup.sh", "*-*-* 03:00:00")
bases = {fname.rsplit(".", 1)[0] for fname in units}
assert len(bases) == 1, "timer and service must share a base name"
for fname, text in units.items():
print(f"--- {fname}")
print(text)
Systemd user timer example#
You don't need root to schedule your own jobs. Put the same two files in ~/.config/systemd/user/ and use --user on every systemctl call:
mkdir -p ~/.config/systemd/user
# create notes-sync.service and notes-sync.timer here
systemctl --user daemon-reload
systemctl --user enable --now notes-sync.timer
systemctl --user list-timers
The unit format is identical. The difference is who runs it: the service executes as your user, under your user's service manager, so it can touch your home directory without any User= setting. Check logs with journalctl --user -u notes-sync.service.
Run systemctl --user daemon-reload after every edit to a unit file. Forgetting it is the most common reason a change "does nothing".
Replacing a cron job with a timer#
Take a typical crontab line:
30 2 * * * /usr/local/bin/rotate-reports.sh
The equivalent pair:
# rotate-reports.service
[Unit]
Description=Rotate reports
[Service]
Type=oneshot
ExecStart=/usr/local/bin/rotate-reports.sh
# rotate-reports.timer
[Unit]
Description=Rotate reports at 02:30
[Timer]
OnCalendar=*-*-* 02:30:00
[Install]
WantedBy=timers.target
What you get over cron: output lands in the journal under the service name, you can see the next run with systemctl list-timers, and you can run the exact same job by hand with systemctl start rotate-reports.service. What you lose is the one-line convenience. Two files for one job is more typing, and I wouldn't migrate a crontab that works and never gets touched.
Migrate one job at a time. Enable the timer, confirm it with list-timers, then delete the crontab line so it doesn't run twice.
The manual also notes that if the unit to activate is already active when the timer elapses, it isn't restarted. It's left running, and there's no concept of spawning new instances. A slow job that overruns its interval won't pile up copies the way overlapping cron runs can.
Monotonic timers and Persistent#
Calendar timers fire at wall-clock times. Monotonic timers count from an event instead, such as boot, and Persistent= controls whether a missed calendar run is made up after downtime. I'm deliberately not giving exact semantics here, because the source I'm working from for this article, the systemd.timer(5) page, doesn't cover those options in the portion I checked. Read the option descriptions in the current systemd.timer(5) manual for your systemd version before relying on them, and verify behavior on a test machine.
My advice: if a job must not be skipped when the machine is off at the scheduled time, treat that as a requirement to test explicitly. Power the machine down across a scheduled time and see what list-timers reports on boot.
Common mistakes and gotchas#
- Mismatched names.
backup.timerwithbackups.servicedoes nothing useful unless you setUnit=. Match the base names or point to the unit. - Enabling the service instead of the timer. Enable and start the
.timer. The service is activated by it. RemainAfterExit=yeson a repeating job. The manual warns these services stay active after the process exits, so they're only activated once and then stay around. A repeating timer won't run them again.- Forgetting
daemon-reload. Edited unit files aren't picked up until you reload. - Relying on cron's environment. Services don't inherit your shell. Use absolute paths in
ExecStart=, and set what the script needs explicitly. - Never testing the expression. One typo in
OnCalendar=can mean a job that runs at the wrong hour for weeks. Runsystemd-analyze calendarfirst. - Targets as timer jobs. Target units don't deactivate on their own. The manual says to set
StopWhenUnneeded=yeson them if a timer should activate them repeatedly.
Deploying timers with Ansible#
Ansible doesn't need anything special here. Template or copy the .service and .timer files into /etc/systemd/system/, run a daemon reload, then enable and start the timer with the ansible.builtin.systemd module. Keep the unit files as plain files in your repo, so the same pair you tested by hand is the one you deploy.
Quick reference checklist#
- Create
name.servicewithType=oneshotand an absoluteExecStart=path. - Create
name.timerwith a[Timer]section andOnCalendar=, plusWantedBy=timers.targetunder[Install]. - Keep the base names identical, or set
Unit=. - Validate:
systemd-analyze calendar "<expression>". - Reload:
systemctl daemon-reload(add--userfor user timers). - Enable the timer:
systemctl enable --now name.timer. - Confirm:
systemctl list-timers. - Dry-run the job:
systemctl start name.service, then readjournalctl -u name.service. - Remove the old cron entry once the timer is proven.
Quick answers
What is the minimum I need for a systemd timer?
A .service file that runs your command and a .timer file with the same base name and an OnCalendar= line in its [Timer] section. Enable the timer, not the service.
How do I make a systemd timer run daily?
Set OnCalendar=daily, or use an explicit time such as OnCalendar=*-*-* 03:00:00 when the hour matters. Check it with systemd-analyze calendar before enabling.
Can a timer activate a service with a different name?
Yes. Set Unit= in the [Timer] section to the unit you want activated. Without it, systemd activates the service with the same name as the timer.
Do systemd user timers need root?
No. Put the files in ~/.config/systemd/user/ and use systemctl --user for every command. The service runs as your user.
Did this solve it?
One tap. No account.
Sorry about that. What was missing?
from 0 readers
Saved you an hour? Send a tip.
USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.
Network
0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
On a computer? Scan the code with your phone wallet, or copy the address.
Pick the network your wallet or exchange uses. USDC works on all six; ETH on Base, Ethereum, Arbitrum and Optimism. Don't send on Tron or Solana: this address doesn't exist there. Tips are optional and unlock nothing; every guide stays free.
Discussion
Start the discussion
Next note