systemd timers schedule well. RunWisp makes every run easier to operate.
Keep systemd when its cgroups, sandboxing, and place at PID 1 matter. Try RunWisp when the harder problem is seeing what ran, why it failed, what it printed, and who was alerted.
Should you switch?
The 10-second version. Skim, decide, scroll down for the receipts.
Try RunWisp if
- You want scheduled tasks and resident services in one operational view.
- You want each firing recorded with status, duration, and captured output.
- Retries, overlap, catch-up, and alert routing should be explicit task settings.
- A Web UI, TUI, and API would make routine inspection and triggering faster.
- The same configuration needs to work across Linux, macOS, WSL, or containers.
Stay on systemd timers if
CPUQuota/MemoryMaxper task are doing real work.DynamicUser=and per-unit sandboxing are load-bearing.- Adding any daemon beyond PID 1 is one moving part too many.
OnCalendar's richer grammar (weekday ranges, last-day-of-month) is in active use.
Different strengths, at a glance
Yes / no — and both columns win on something.
| Capability | systemd timers | RunWisp |
|---|---|---|
| Cross-platform (macOS / WSL / Alpine) | ✗ | ✓ |
| One file per scheduled task | ✗.timer + .service | ✓ |
| Per-firing run rows | ~journal invocations (v257+) | ✓normalized run history |
| Captured stdout/stderr per run | ✓journal; per-run filter v257+ | ✓downloadable |
| Retries with backoff curve | ~RestartSteps= (v254+), no attempt cap | ✓ |
| Slack / Telegram / email notifiers | ~wire through OnFailure= | ✓built in |
| Coalesced flap notifications | ✗ | ✓ |
| Overlap policy | ~active unit is not restarted | ✓skip / queue / kill |
| Catch-up after downtime | ✓Persistent=true | ✓catch_up = 0 / 1 / N |
| Web UI | ~Cockpit, no run history | ✓ |
| TUI over SSH | ✗ | ✓ |
| cgroup resource limits | ✓ | ✗ |
| Per-task identity (User=) | ✓+ DynamicUser | ✓user = |
| Schedule grammar | ✓OnCalendar, ranges and ~ | ~6-field cron + @every |
| Built-in jitter | ✓RandomizedDelaySec | ✓jitter |
| Already on the box (no install) | ✓ | ✗ |
| Daemon needs supervising itself | ✗is PID 1 | ✓ |
Pros & cons
What systemd timers still do better
- Already PID 1; no supervisor-of-the-supervisor question.
- Full cgroup surface (
CPUQuota,MemoryMax,IOWeight) per unit. - Per-unit
DynamicUser=, sandboxing, and namespace isolation. OnCalendar=ranges (Mon..Fri 09:30) and last-day-of-month schedules.- Nothing extra to install on a systemd distribution.
What RunWisp adds
- Same daemon, same TOML on Linux, macOS, WSL, Docker.
- Successful, failed, start-failed, missed, and skipped outcomes share one queryable model across the Web UI, TUI, CLI, and API.
catch_up = 1 / N / 0instead of Persistent= or its absence; a tick missed while down lands as amissedrun and an alert.on_overlap = "skip" / "queue" / "kill"as a named field.jitterstaggers tasks that share a fire time;user =drops to another uid;run_on_startcoversOnBootSec.- Notifiers route to Slack, Telegram, Discord, ntfy, email or a webhook and coalesce flap storms.
- Schedule travels with
runwisp.toml; back up the file and you back up the schedule. - TUI works over SSH/tmux for headless inspection.
The simple case
The same nightly backup at 03:00, expressed as a native systemd unit pair or one RunWisp task.
# /etc/systemd/system/backup.timer
[Unit]
Description=Nightly backup timer
[Timer]
OnCalendar=03:00
Unit=backup.service
[Install]
WantedBy=timers.target
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh # runwisp.toml
[tasks.backup]
cron = "0 3 * * *"
run = "/usr/local/bin/backup.sh" The version that runs in prod
Both versions add production concerns explicitly: downtime, jitter, failures, identity, retries, and retention.
# /etc/systemd/system/backup.timer
[Unit]
Description=Nightly backup timer
[Timer]
OnCalendar=03:00
Persistent=true
RandomizedDelaySec=300
Unit=backup.service
[Install]
WantedBy=timers.target
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup
OnFailure=backup-alert.service
[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/backup.sh
RuntimeMaxSec=55min
Restart=on-failure
RestartMode=direct
RestartSec=30s
RestartSteps=4
RestartMaxDelaySec=5m
StandardOutput=journal
StandardError=journal # runwisp.toml, production
[tasks.backup]
description = "Snapshot the data volume to off-site storage"
cron = "0 3 * * *"
catch_up = 1 # one catch-up run after host downtime
on_overlap = "skip"
timeout = "55m"
retry_attempts = 2
retry_delay = "30s"
retry_backoff = "exponential"
keep_runs = 60
notify = ["slack-ops"]
run = "/usr/local/bin/backup.sh" Translate one task, not your whole system
Start with a low-risk timer. RunWisp can import systemd units into annotated TOML, with TODOs where the semantics need a human decision.
| systemd timers | RunWisp |
|---|---|
.timer + .service pair | One [tasks.*] stanza |
OnCalendar=03:00 | cron = "0 3 * * *" |
OnCalendar=Mon..Fri 09:30 | cron = "30 9 * * 1-5" |
Persistent=true | catch_up = 1 (default) |
OnFailure=alert.service | notify = ["slack-ops"] |
Restart=on-failure + RestartSec= | retry_attempts + retry_delay + retry_backoff |
ExecStart= | run = |
User=backup | user = "backup" (daemon running as root) |
journalctl -u backup.service --list-invocations | Normalized run history in Web UI, TUI, CLI, and API |
OnBootSec= | run_on_start = "boot" (once per machine boot) |
OnUnitActiveSec= | cron = "@every …" |
CPUQuota= / MemoryMax= | Wrap with systemd-run --scope |
RuntimeMaxSec=55min | timeout = "55m" |
RandomizedDelaySec=300 | jitter = "5m" |
Gotchas
Per-task user, but no DynamicUser
When the daemon runs as root, user = "backup" (or "backup:backup", name or numeric id) runs that task or service as another uid. What you don't get is systemd's DynamicUser=, namespace isolation, or per-unit sandboxing; if those are load-bearing, the unit file is doing real work for you.
No built-in resource limits
No analogue to CPUQuota or MemoryMax in runwisp.toml. Combine with systemd-run --scope --user --slice=… inside run, or any cgroup-aware launcher.
Run RunWisp under systemd
On a systemd host, keep PID 1 doing what it does well. runwisp service install writes a Type=simple unit running runwisp daemon with Restart=on-failure; --print emits it without installing. In a container, use the entrypoint.
FAQ
Do I need to migrate a timer to try RunWisp?
Can the RunWisp daemon itself run under systemd?
Is OnCalendar= syntax supported by RunWisp?
Does systemd have per-invocation history?
How is failure surfaced compared to OnFailure= unit dispatch?
What's the boot-time catch-up story compared to Persistent=true?
Are there built-in resource limits like CPUQuota and MemoryMax?
Try the operating workflow before moving a timer.
Start with npx runwisp demo or bunx runwisp demo: a temporary populated instance that leaves your systemd units alone. If the run history and controls earn their keep, import one low-risk timer first.
curl -fsSL https://get.runwisp.com | sh