> ## Content Index
> Fetch the complete content index at: https://blog.sudowl.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# systemd Timers Instead of cron
- URL: https://blog.sudowl.com/systemd-timers-instead-of-cron/
- Published: 2026-06-23T10:00:00.000Z
- Updated: 2026-10-11T08:47:37.000Z
- Description: Replace cron jobs with systemd timers — .timer + .service pairs, OnCalendar syntax, Persistent catch-up, journald logging, and when cron is still fine.
- Author: Lukas
- Tags: Linux, Ubuntu

I moved almost everything off cron and onto systemd timers, and the reason is boring: when a timer job fails, I can actually see why. `journalctl -u myjob` gives me the exit code and the full output. A cron job that fails emails root on a box with no mail configured and vanishes into the void.

**TL;DR:** a timer is two files — a `.service` that does the work and a `.timer` that says when:

```ini
# /etc/systemd/system/backup.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh

```

```ini
# /etc/systemd/system/backup.timer
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target

```

```bash
systemctl enable --now backup.timer

```

Details below.

## Why bother, honestly

cron works. I'm not religious about this. But timers buy you real things:

- **Logging that exists.** Output goes to the journal: `journalctl -u backup.service`. No `MAILTO`, no `>> /var/log/thing.log 2>&1` boilerplate on every line.
- **No overlap.** A `oneshot` service won't start again while the previous run is still going. cron will happily stack five copies of a slow job until the box falls over.
- **Catch-up.** `Persistent=true` runs a missed job when the machine boots. A laptop or a VM that was off at 03:00 still gets its backup. cron just skips it.
- **Dependencies.** `After=network-online.target` means "don't run until the network's actually up". cron has no concept of that.
- **Randomized spread.** `RandomizedDelaySec` jitters start times so a fleet of servers doesn't all hammer the same backup target at exactly 03:00.

When is cron still fine? A personal box, a one-line job, something you'll never debug. Don't rewrite working cron entries for the sake of it. New jobs I care about go to timers.

## The .service half

The service is a normal unit with `Type=oneshot` — it runs, finishes, and that's a successful "completed", not a crash:

```ini
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup job
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
# optional: run as a specific user, load secrets from a root-only file
User=backup
EnvironmentFile=/etc/backup.env

```

You can run this on demand any time to test it, without waiting for the timer:

```bash
systemctl start backup.service
journalctl -u backup.service -n 50 --no-pager

```

That alone is a reason to switch — testing a cron line means editing the crontab to a minute from now and waiting. New to units in general? See [/systemd-service-unit-file/](https://blog.sudowl.com/systemd-service-unit-file/).

## The .timer half

Same base name, `.timer` extension. systemd pairs `backup.timer` with `backup.service` automatically:

```ini
# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup nightly

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target

```

```bash
systemctl daemon-reload
systemctl enable --now backup.timer

```

Note you enable the **timer**, not the service. The service has no `[Install]` section because it's triggered, not enabled directly.

## OnCalendar syntax, decoded

This is the part that trips everyone up coming from cron's five fields. systemd uses `DOW YYYY-MM-DD HH:MM:SS`:

```ini
OnCalendar=*-*-* 03:00:00          # every day at 03:00
OnCalendar=Mon *-*-* 09:00:00      # every Monday 09:00
OnCalendar=*-*-01 00:00:00         # first of every month, midnight
OnCalendar=*-*-* *:00:00           # top of every hour
OnCalendar=*-*-* *:0/15:00         # every 15 minutes
OnCalendar=Mon..Fri *-*-* 18:00:00 # weekdays at 18:00
OnCalendar=hourly                  # shorthand; also daily, weekly, monthly

```

Don't guess — validate it:

```bash
systemd-analyze calendar "Mon..Fri *-*-* 18:00:00"
# prints the next few times it will actually fire

```

That command has saved me from "why didn't it run" more than once. There's also `OnBootSec=` and `OnUnitActiveSec=` for "5 minutes after boot" and "every 6 hours since last run" style schedules, which cron simply can't express.

## See what's scheduled

One command shows every timer, when it last ran and when it fires next — the dashboard cron never had:

```bash
systemctl list-timers --all

```

```
NEXT                        LEFT     LAST                        PASSED  UNIT            ACTIVATES
2026-06-23 03:00:00 UTC     8h left  2026-06-22 03:00:12 UTC     15h ago backup.timer    backup.service

```

## Migrating a cron job

Take a crontab line:

```cron
0 3 * * * /usr/local/bin/backup.sh

```

That becomes the `.service` \+ `.timer` pair above, with `OnCalendar=*-*-* 03:00:00`. The script doesn't change. Once the timer's verified with `list-timers` and a manual `systemctl start`, delete the crontab line so it doesn't run twice.

## Gotchas

- **Enabling the wrong unit.** Enable the `.timer`, not the `.service`. Enable the service and it tries to run at boot and never again.
- **`Type=simple` for a script.** Use `Type=oneshot` for a job that runs and exits. With the default `Type=simple`, systemd considers it "started" the instant it launches, which breaks overlap protection and ordering.
- **Relative paths and missing env.** Timers run with a minimal environment — no `cd`, none of your shell's `PATH` tweaks, no profile. Use absolute paths in the script, set what you need in the unit. This is the classic "works in my shell, fails as a timer" trap.
- **OnCalendar timezone.** It follows the system timezone unless you set `OnCalendar` with a zone or run with `Persistent`. Check `timedatectl` if a job fires an hour off.
- **Secrets.** Don't inline passwords in `ExecStart`. Use `EnvironmentFile=` with a `0600` root-owned file.

For real-world timers in action, the nightly backup-and-restart setup in [/hytale-server-backups-auto-restart-systemd/](https://blog.sudowl.com/hytale-server-backups-auto-restart-systemd/) is all timers, and rotating logs the systemd way pairs nicely with [/logrotate-linux-log-rotation/](https://blog.sudowl.com/logrotate-linux-log-rotation/).

---

### Related posts

- [bpftrace One-Liners I Actually Use on Production Linux](https://blog.diengdoh.com/bpftrace-linux-observability/?ref=blog.sudowl.com)
- [Nginx Reverse Proxy: The Config I Actually Use](https://blog.diengdoh.com/nginx-reverse-proxy-setup/?ref=blog.sudowl.com)
- [Ubuntu Server Initial Setup Checklist (2026 Edition)](https://blog.diengdoh.com/ubuntu-server-initial-setup-checklist/?ref=blog.sudowl.com)