Comparison · vs systemd timers
RunWisp vs systemd timers: one TOML, three platforms, run history included.
A real .timer + .service pair next to the equivalent runwisp.toml. Platform reach, run history, OnCalendar vs cron, what systemd still does that RunWisp doesn't.
Should you switch?
The 10-second version. Skim, decide, scroll down for the receipts.
Switch to RunWisp if
- Your fleet includes macOS, WSL, Alpine, or no-systemd containers.
- You want runs as rows in a table, not "filter journalctl by unit and time."
- One TOML stanza beats writing two unit files and an enable command.
- Failure should fan out to Slack, not dispatch another .service.
- Catch-up after downtime should be a named policy, not Persistent= or its absence.
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 (ranges, weeks of month) is in active use.
Yes / no, at a glance
Yes / no, both columns win on something.
| Feature | systemd timers | RunWisp |
|---|---|---|
| Cross-platform (macOS / WSL / Alpine) | | |
| One file per scheduled task | .timer + .service | |
| Per-firing run rows | | journalctl filter |
| Captured stdout/stderr per run | ~ in journal | downloadable |
| Retries with backoff curve | ~ Restart=, RestartSec= | |
| Slack / Telegram / email notifiers | OnFailure=unit | |
| Coalesced flap notifications | | |
| Overlap policy | | skip / queue / terminate |
| Catch-up after downtime | Persistent=true | named policy |
| Web UI | | |
| TUI over SSH | | |
| cgroup resource limits | | |
| Per-task identity (User=) | + DynamicUser | user = |
| Schedule grammar | OnCalendar rich | ~ 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, weeks of month). - Nothing extra to install on a systemd distribution.
What RunWisp adds
- Same daemon, same TOML on Linux, macOS, WSL, Docker.
- Every firing is a queryable run row with status, duration, captured streams.
-
catch_up = "latest" / "all" / "skip"instead of Persistent= or its absence; a tick missed while down lands as amissedrun and an alert. -
on_overlap = "skip" / "queue" / "terminate"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 / email / 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
Nightly backup at 03:00. Two unit files vs one stanza.
# /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" Production-grade
Same backup, hardened. Persistent + RandomizedDelaySec + OnFailure on the left; named fields on the right.
# /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
Restart=on-failure
RestartSec=30s
StandardOutput=journal
StandardError=journal # runwisp.toml, production
[tasks.backup]
description = "Snapshot the data volume to off-site storage"
cron = "0 3 * * *"
catch_up = "latest" # 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_on_failure = ["slack-ops"]
run = "/usr/local/bin/backup.sh" Migration cheat sheet
systemd directive on the left, runwisp.toml field on the right.
| 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 = "latest" (default) |
OnFailure=alert.service | notify_on_failure = ["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 | Run rows in Web UI; per-run log download |
OnBootSec= | run_on_start = true |
OnUnitActiveSec= | cron = "@every …" |
CPUQuota= / MemoryMax= | Wrap with systemd-run --scope |
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.
The daemon needs supervising
systemd is PID 1; RunWisp isn't. runwisp service install writes the unit for you (systemd or launchd; --print emits it without installing) — a Type=simple unit running runwisp daemon with Restart=on-failure. In a container, use the entrypoint.
FAQ
Yes, and that's the recommended pattern on a systemd host. A Type=simple unit launching runwisp daemon gives you supervision-of-the-supervisor for free.
Not directly. RunWisp parses standard cron — five fields, or six with an optional leading seconds field (*/30 * * * * * for every 30s) — plus @hourly / @daily / @every aliases. Most OnCalendar= specs translate one-for-one. OnBootSec= maps to run_on_start = true; OnUnitActiveSec= maps to cron = "@every …".
Per-task notify_on_failure routes (in-app, Slack, Telegram, email). Outbound notifiers coalesce by (task, event, end reason) inside coalesce_window (default 1h), with a window-close summary.
catch_up = "latest" is the equivalent default. "all" replays every missed tick up to max_catch_up_runs. "skip" is the no-catch-up explicit choice.
No. Wrap with systemd-run --scope --user --slice=… or another cgroup-aware launcher. On non-systemd hosts the kernel's cgroup primitives are still available; the wiring is yours.
More comparisons: RunWisp vs cron · RunWisp vs supervisord · RunWisp vs PM2 .
Cross the platform line. Keep the run history.
One TOML stanza per scheduled task. Same daemon on Linux, macOS, WSL, and Docker. Wherever you do the work, the schedule travels with it.