CoreMesh

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.

Systemd Timer Example: A Working Unit Pair to Copy

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.

ini
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
ini
# /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:

ini
[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.target and timers.target, unless you set DefaultDependencies=no. Only timers involved with early boot or late shutdown should do that.
  • A timer with at least one OnCalendar= line also waits for time-set.target and time-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.

ini
[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:

bash
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:

bash
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:

bash
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.

python
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:

bash
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:

text
30 2 * * * /usr/local/bin/rotate-reports.sh

The equivalent pair:

ini
# rotate-reports.service
[Unit]
Description=Rotate reports

[Service]
Type=oneshot
ExecStart=/usr/local/bin/rotate-reports.sh
ini
# 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.timer with backups.service does nothing useful unless you set Unit=. 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=yes on 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. Run systemd-analyze calendar first.
  • Targets as timer jobs. Target units don't deactivate on their own. The manual says to set StopWhenUnneeded=yes on 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#

  1. Create name.service with Type=oneshot and an absolute ExecStart= path.
  2. Create name.timer with a [Timer] section and OnCalendar=, plus WantedBy=timers.target under [Install].
  3. Keep the base names identical, or set Unit=.
  4. Validate: systemd-analyze calendar "<expression>".
  5. Reload: systemctl daemon-reload (add --user for user timers).
  6. Enable the timer: systemctl enable --now name.timer.
  7. Confirm: systemctl list-timers.
  8. Dry-run the job: systemctl start name.service, then read journalctl -u name.service.
  9. 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.

Saved you an hour? Send a tip.

USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.

Base

Network

0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
Open in wallet · 5 USDC

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

`code` and ``` blocks work

No account needed. Comments are read before they show up, usually the same day. Your email is never asked for.

Indra Wijaya

Builds and runs AI agents, bots and content pipelines, and writes down what works and what broke.

About · Email · RSS

Keep reading

New notes, no spam.

One post at a time, only when there's something worth reading. Follow in any feed reader.

Subscribe via RSS