# systemd-timer: scheduling instead of cron

LLMS index: [llms.txt](/en/llms.txt)

---

cron works, but its logs are flat text files with no structure, and service dependencies require workarounds like embedding `Requires=` logic inside shell scripts. systemd-timer fixes this: unified management interface, logs in journald, dependencies through the familiar `After=` and `WantedBy=` directives — all in one stack.

## Structure: .service and .timer

A timer is a separate unit that triggers a `.service`. The separation is intentional: the service can be invoked manually or on a schedule.

```
/etc/systemd/system/
├── backup.service
└── backup.timer
```

backup.service is a regular unit, startable via `systemctl start backup.service`.

backup.timer is the trigger. Without it, the service will not run on a schedule.

```ini
# /etc/systemd/system/backup.service
[Unit]
Description=Backup to storage
After=network.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
```

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

[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target
```

`Persistent=true` — if the machine was off when the timer fired, it catches up after boot.

## Calendar Timers (OnCalendar)

`OnCalendar=` format is the closest analog to cron expressions, but with different syntax.

| Example | Fires |
|---------|-------|
| `OnCalendar=daily` | Every day at 00:00 |
| `OnCalendar=*-*-01 03:00` | First day of each month at 03:00 |
| `OnCalendar=*-*-* 02:00` | Every day at 02:00 |
| `OnCalendar=09..17:00` | Every hour from 09:00 to 17:00 |
| `OnCalendar=*:0/15` | Every 15 minutes |
| `OnCalendar=Mon..Fri 09:30` | Weekdays at 09:30 |

Multiple values can be specified comma-separated:

```ini
[Timer]
OnCalendar=09:00,12:00,18:00
```

To validate syntax before applying:

```bash
systemd-analyze calendar '*-*-01 03:00'
```

Output shows the next firing date. Useful when "every second Tuesday" becomes `*-*-1..31 03:00` — verify before deployment.

## Monotonic Timers

Monotonic timers count from an event, not the clock.

| Directive | Fires |
|-----------|-------|
| `OnBootSec=5min` | 5 minutes after boot |
| `OnStartupSec=10min` | 10 minutes after systemd manager starts |
| `OnUnitActiveSec=1h` | 1 hour after the service last ran |
| `OnUnitInactiveSec=1d` | 1 day after the service stopped |

`OnBootSec` and `OnStartupSec` are similar, but `OnBootSec` resets on each boot while `OnStartupSec` counts from when the systemd manager started. In practice, the difference shows in containers and during live migration.

Combination `OnBootSec` + `OnUnitActiveSec` implements "every hour, but not before boot":

```ini
[Timer]
OnBootSec=10min
OnUnitActiveSec=1h
```

## Verification: systemctl list-timers

After enabling and starting:

```bash
sudo systemctl enable --now backup.timer
sudo systemctl list-timers --all
```

```
NEXT                        LEFT     LAST                        PASSED  UNIT            ACTIVATES
Mon 2024-11-18 00:00:00 MSK  6h left  Sun 2024-11-17 00:00:08 MSK 18h ago backup.timer   backup.service
```

Without `--all` shows only active timers. `NEXT` is when it fires, `LEFT` is how long until then.

If the timer does not appear in the list — check status:

```bash
systemctl status backup.timer
systemctl status backup.service
journalctl -u backup.service -n 50
```

Common cause of a silent timer is a missing `WantedBy=timers.target`.

## User-Level Timers (systemd --user)

Not every task needs root. Deployment scripts, home-directory cache cleanup, periodic git fetch — better under a user.

Units go in `~/.config/systemd/user/`:

```bash
mkdir -p ~/.config/systemd/user
```

```ini
# ~/.config/systemd/user/sync.service
[Unit]
Description=Git sync

[Service]
Type=oneshot
WorkingDirectory=%h/projects/monorepo
ExecStart=/usr/bin/git fetch --all

[Install]
WantedBy=default.target
```

```ini
# ~/.config/systemd/user/sync.timer
[Unit]
Description=Git sync every hour

[Timer]
OnBootSec=2min
OnUnitActiveSec=1h

[Install]
WantedBy=timers.target
```

Activation:

```bash
systemctl --user enable --now sync.timer
```

User timers need linger if they should run without login:

```bash
sudo loginctl enable-linger username
```

## Common Mistakes

**Missing OnCalendar or monotonic timer.** Without a `[Timer]` directive, the timer will never fire. `systemctl start backup.timer` starts the unit, but without a schedule it just sits there.

**Forgot WantedBy.** Without `[Install]`, the unit does not persist across reboots. `systemctl enable backup.timer` completes without errors, but it will not appear in `list-timers`.

**ExecStart in .timer.** A timer only triggers its linked service. Placing `ExecStart` in `.timer` causes systemd to ignore it and pull from `.service`.

**Persistent with no prior run.** On first activation, `Persistent=true` does nothing — there is no "last run" in history. The service fires only at the next scheduled time.

## Logging: cron vs timer in journald

Cron sends output to syslog or `cron.log`, structure is a text line with timestamp. Parsing requires grep or awk.

systemd-timer writes service stdout/stderr directly to journald:

```bash
journalctl -u backup.service -f
```

Filtering by time, unit, severity — standard `journalctl` flags:

| Flag | Effect |
|------|--------|
| `-u backup.service` | This unit only |
| `-n 100` | Last 100 lines |
| `-f` | Follow in real time |
| `--since "1 hour ago"` | Time range filter |
| `-p err` | Errors only |

Actual firing time is recorded in metadata. Build execution history without parsing text logs.

```bash
journalctl -u backup.timer -o short-iso -n 20
```

Output includes real start time, simplifying debugging of missed triggers.

## Summary

Switching from cron to systemd-timer pays off when the workload already lives in a systemd environment. Unified management, dependencies via `After=`, logs in journald — gains are tangible. For a crontab one-liner, systemd-timer is overkill, but for scripts with dependencies, logging, and boot persistence — a mature tool.
