Skip to content

[daemon]

[daemon] holds the handful of settings that apply to the daemon as a whole, rather than to any one task or service. The whole section is optional — leave it out and the built-in defaults below take over.

You won’t find the data directory or listen address in here, and that’s on purpose: they have to be set before the config file is even read, so they live on the CLI instead (or in however your supervisor invokes runwisp). They’re documented on this page anyway, since in practice you tend to think about them together.

[daemon]
shutdown_timeout = "10s"
external_url = "https://runwisp.example.com"
tls = "off"
tls_cert = ""
tls_key = ""
metrics_enabled = false
metrics_listen = ""
include = ["conf.d/*.toml"]
# include_cron = ["/etc/crontab", "/etc/cron.d/*"]
Key Default What it does
shutdown_timeout "10s" Whole-daemon shutdown budget. After SIGTERM, the daemon SIGKILLs any in-flight runs that haven’t exited within this window so the process can actually exit.
external_url unset Public base URL of this daemon’s Web UI. When set, notification messages (Slack, Telegram) include a deep-link back to the run; when unset, the link line is omitted.
tls "off" "off" always serves plain HTTP; "auto" serves HTTPS whenever the bind address is non-loopback (self-signing a cert on first boot). Loopback binds stay HTTP either way. Ignored when tls_cert/tls_key are set.
tls_cert unset Path to a PEM certificate to serve instead of the self-signed one. Set together with tls_key. When set, HTTPS is served on every bind address, loopback included.
tls_key unset Path to the PEM private key matching tls_cert. Both keys are set together or not at all.
metrics_enabled false Master switch for the Prometheus-compatible /metrics endpoint. Off by default — task names and the daemon version label are visible to anyone who can reach the endpoint, so it stays closed until you turn it on.
metrics_listen unset Optional host:port for a dedicated metrics listener (e.g. "127.0.0.1:9478"). When set, /metrics is only reachable on this address — never on the main UI/REST listener. Only consulted when metrics_enabled = true.
include unset Glob patterns for extra TOML files to merge into this config at load. Lets you split tasks across conf.d/*.toml instead of one giant file. Only valid in the root config.
include_cron unset Glob patterns for real crontabs (system, cron.d, or per-user spool) to read as live task definitions at every load and reload. Point RunWisp at what cron already reads instead of converting everything up front. Only valid in the root config.

Think of shutdown_timeout as the budget for the whole daemon. Every task and service still has its own graceful_stop, but it has to fit inside that overall cap. If some task’s graceful_stop is longer than shutdown_timeout, the daemon warns you about it by name at boot — because in that situation it’ll SIGKILL the straggler before its per-task grace window is even up.

You’ve got three ways out of that: raise shutdown_timeout, lower the per-task graceful_stop, or just accept that the task’s cleanup hook might get cut short when the daemon goes down.

external_url is the public address where someone — you, or whoever gets a notification — actually reaches this daemon’s Web UI. That might be https://runwisp.example.com behind a reverse proxy, or http://192.168.1.50:9477 on a LAN. Trailing slashes get stripped, and the scheme has to be http or https.

The daemon never calls out to this URL itself — it’s purely for rendering. When a Slack or Telegram notification fires, the template tacks on a “View run” link like <external_url>/tasks/<task>/<id>, which drops you straight into that run’s detail panel in the dashboard.

Leaving external_url unset is perfectly fine and fully supported. Notifications still render in full — they just skip the link line rather than print a broken URL.

By default RunWisp serves plain HTTP on every bind address, loopback or not. Set tls = "auto" and it’ll serve HTTPS by itself instead, with no certificate to obtain and nothing to wire up:

  • Loopback (--host 127.0.0.1, the default): stays plain HTTP even with tls = "auto". There’s nothing on the wire to eavesdrop, and curl http://localhost:9477 just works for local dev.
  • Non-loopback (--host 0.0.0.0, a LAN IP, …) with tls = "auto": the daemon generates a long-lived self-signed certificate on first boot, stores it under <data>/tls/, and serves HTTPS. Auth cookies and CHAP responses never cross a real network in cleartext.

Leave tls unset (or "off") and a non-loopback bind serves plain HTTP — the daemon prints a loud startup banner when it does, since that’s the one case where auth tokens really can cross a real network in the clear.

Because the cert is self-signed, the first browser visit shows the usual “not trusted” warning, and the CLI/TUI pin the cert on first connect (trust-on-first-use, like SSH). To let you verify you’re talking to the right daemon, the startup log prints the certificate’s SHA-256 fingerprint:

Serving HTTPS bind=0.0.0.0 cert=self-signed fingerprint=sha256:1a2b3c…

Compare that against the warning your browser shows, or the fingerprint the CLI pins, and you know the connection is genuine. If you ever regenerate the cert (delete <data>/tls/ and restart), the CLI will refuse to connect until you clear the old pin — same loud “host identity changed” behaviour as ssh.

Bring your own certificate by pointing tls_cert and tls_key at a PEM pair — a cert from your internal CA, say, or one a tool like mkcert made. Supplying a pair forces HTTPS on every bind address (loopback included) and skips the self-signed flow entirely.

Leave it off (the default) when something else already terminates TLS — most commonly a reverse proxy (nginx, Caddy, Traefik) doing TLS out front and forwarding plain HTTP to RunWisp on a private network. In that case set RUNWISP_TRUSTED_PROXIES to the proxy’s CIDR so the daemon honours X-Forwarded-Proto and still marks session cookies Secure. A non-loopback bind on tls = "off" prints a loud startup banner — plain HTTP on a real network exposes your auth tokens, and that should never be silent.

What RunWisp deliberately does not do is ACME / Let’s Encrypt: that needs outbound network and a public domain, which would break the local-first, offline-complete promise. For a publicly-trusted cert, use a reverse proxy or supply your own pair.

metrics_enabled is the gate on the OpenMetrics scrape endpoint at /metrics, and it’s off by default for a reason: runwisp_task_active_runs exposes your task names as label values, and runwisp_build_info exposes the daemon version — that’s exactly the kind of recon detail a publicly-reachable daemon shouldn’t be handing out unasked. Flip it to true when you’re ready to wire RunWisp into Prometheus, Grafana Agent, or an OpenTelemetry collector. The full label list and a sample scrape config are over in Operations / Metrics.

With metrics_enabled = true, the endpoint rides on the main UI/REST listener by default. Point metrics_listen at something like "127.0.0.1:9478" (any host:port works) and /metrics binds there instead. That’s the knob you want when --host 0.0.0.0 puts the dashboard out in public but you’d rather keep the scrape surface on loopback. The dedicated listener serves only /metrics — the UI, REST API, and /health all stay on the main listener.

The dedicated metrics listener is always plain HTTP — auto-HTTPS (see tls) does not wrap it. Keep it on loopback (or a private interface like a Tailscale address) and let your scraper reach it locally or through a proxy; don’t expose it on a public interface.

And if you set metrics_listen but never turned metrics_enabled on, the daemon rejects it at boot instead of quietly ignoring it.

Once you’ve got more than a handful of tasks, one runwisp.toml gets unwieldy. include lets you break it up: point it at one or more glob patterns, and every matching file gets merged in as if you’d pasted it into the root config.

runwisp.toml
[daemon]
include = ["conf.d/*.toml", "services/*.toml"]
[tasks.heartbeat]
run = "curl -fsS https://example.com/ping"
# conf.d/backups.toml — no [daemon] here, just tasks
[tasks.nightly-backup]
run = "/opt/backup.sh"
cron = "0 3 * * *"

A few rules keep “what wins” obvious:

  • Patterns are relative to the file that wrote them. A glob in the root config resolves against the root config’s directory; the same goes for any relative path (run’s working_dir, env_file, compose_file, ${file:...}) inside an included file — those resolve against that file’s directory, not the root’s. So a conf.d/backups.toml referencing env_file = "backup.env" looks for conf.d/backup.env.
  • Tasks, services, compose blocks, notifiers, and routes accumulate. Everything from the root and every matched file is pooled together. Merge order is the root first, then matched files sorted by path — but order only matters for tie-breaking error messages, since…
  • Names must be unique across all files. Two files defining a task, service, or [compose.*] alias with the same name is a hard error that names both files. No silent “last one wins”.
  • The big singleton tables stay in the root. [daemon], [scheduler], [defaults], [storage], and [notify] may only appear in the root config — setting one in an included file is an error. This keeps daemon-wide settings and [defaults] inheritance in exactly one place.
  • Includes don’t nest. An included file can’t have its own [daemon].include. One level, root-out.

Editing — or adding, or deleting — any included file shows up the same way a root-config edit does: the daemon flags the config as stale in /api/info (and the UI), and a runwisp reload (or a restart) re-globs the patterns and picks up the change. Includes are resolved fresh on every load, so dropping a new conf.d/*.toml in place gets it merged on the next reload — no restart required.

include merges RunWisp TOML. include_cron does the same thing with real crontabs — files in cron’s own format, which RunWisp reads and never writes:

runwisp.toml
[daemon]
include_cron = [
"/etc/crontab",
"/etc/cron.d/*",
"/var/spool/cron/crontabs/*", # everyone's own crontabs (needs a root daemon)
]

That’s the whole cutover. Every job in those files becomes a RunWisp task with captured output, run history, and a page in the UI, and you haven’t rewritten a single line of cron yet.

You don’t have to write that block by hand. sudo runwisp takeover finds the crontabs on the box and writes exactly this for you — scaffolding a runwisp.toml if there isn’t one, or inserting the include_cron key into the one you have, comments and formatting untouched. The one thing it won’t do is edit an include_cron you already maintain: that list is yours, so if it misses a crontab RunWisp prints what to add rather than changing it.

Editing a crontab works exactly the way editing an included TOML file works: the daemon flags the config stale, and runwisp reload re-reads it. So crontab -e followed by runwisp reload is the whole workflow.

When you’re ready to convert a job to native TOML, runwisp promote <task> writes its definition into runwisp.toml — verbatim, the definition that was already running — and comments out the crontab line it came from so a still-live cron won’t double-fire it. See Graduating a job.

The path decides how a file is read — you don’t flag it:

Path Format Jobs run as
/etc/crontab, any cron.d/* system crontab the line’s own sixth-field username
/var/spool/cron/crontabs/<user> per-user spool <user> — taken from the filename
/var/spool/cron/<user> per-user spool <user> (RHEL/SUSE layout)
anything else crontab -l dump whoever the daemon runs as

Per-user crontabs need a root daemon, because only root can run a job as somebody else. If RunWisp can’t become the account a spool file belongs to, it says so and refuses that file rather than running those jobs as itself. Reading your own crontab without root works fine.

The filename is only a claim about who owns a spool crontab; RunWisp also requires the file to actually be owned by that account. That pairing is what makes taking an identity from a filename safe, and it’s the same check cron makes.

A glob only picks up the files cron itself would schedule, and the rule depends on which kind of directory it’s globbing:

  • /etc/crontab and any cron.d/* — regular files whose names are made of letters, digits, - and _. That’s how /etc/cron.d/backup.dpkg-old, a hand-renamed job.disabled, and a README stay out of your schedule.
  • A spool directory (/var/spool/cron/crontabs/*) — any name that could be an account, which is a much looser rule: cron takes the filename as-is and looks it up, so john.doe or a $-suffixed service account is a perfectly normal crontab there. The one thing excluded on purpose is tmp.*, which is the temp file crontab -e writes before its atomic rename — never a real crontab, even mid-edit.

Either way RunWisp skips what cron would skip too, and says which files it skipped and why.

Name a path outright and it’s read whatever it’s called: include_cron = ["/etc/cron.d/backup.cron"] is you overruling the rule on purpose, which is allowed.

A spool directory that exists but isn’t readable by this daemon (/var/spool/cron/crontabs ships 1730 root:crontab on Debian, so a non-root RunWisp can’t list it at all) is reported too — a plain glob can’t tell “empty directory” apart from “directory I’m not allowed to open,” and the second one means cron itself may be running jobs your include_cron can’t see at all. Run RunWisp as root, or as the account that owns the crontabs you want, to read it.

  • A job RunWisp can’t reproduce is skipped, and the rest of the file still runs. That’s what cron does with a malformed entry, so it’s what RunWisp does too. Every skip is reported by file:line at boot, by runwisp reload, runwisp status and runwisp validate, so you know exactly which jobs aren’t running. runwisp import cron on the same file explains each one in more detail.
  • A whole file RunWisp can’t read gets the same treatment as one bad line: it’s skipped, and every other include_cron file still loads. A spool crontab for a userdel’d account, a file at the wrong mode, an NSS-only owner — none of it takes your other tasks down with it. The only time this becomes a hard config error is every matched file failing at once, since that’s a sign the pattern itself is wrong rather than one file being bad.
  • A user column (or a spool filename) that names an NSS/LDAP/SSSD account isn’t visible to a statically-linked RunWisp binary. CGO_ENABLED=0 — the default release build — makes os/user read only /etc/passwd, so a job that belongs to a directory-only account is skipped even though crond, which is usually dynamically linked, runs it fine. getent passwd <name> tells you whether that’s what’s happening on a given box; a CGO-enabled build (or the account also existing in /etc/passwd) fixes it.
  • A MAILTO= gets reported, not honoured. Cron mailed a job’s output; RunWisp captures it instead and notifies on failure. Wire up a sendmail notifier — it needs no configuration on a box that was already mailing cron output — and the warning stops. A SHELL= that isn’t an absolute path is reported the same way.
  • Missed ticks aren’t re-fired. Cron has no concept of one, so neither does a cron-sourced task: starting the daemon at 15:00 will not run last night’s 02:00 job. The gap is still recorded as a missed run, which is more than cron gave you. promote the task if you want catch-up.
  • Overlapping runs queue instead of piling up. Cron starts another copy of a job that outruns its own schedule; RunWisp queues the next one. This is deliberate — an unbounded pile-up is why cron jobs get wrapped in flock — and promote plus max_concurrent gets the old behaviour back if you actually wanted it.
  • Task names are derived from the command, so two crontabs can derive the same one. The second becomes backup-<crontab name> rather than quietly replacing the first, and the rename is reported alongside the skips.
  • RunWisp won’t take commands from a file anyone could have written. A crontab that’s group- or world-writable, or owned by nobody relevant, is refused — the whole load fails rather than running shell from it. A cron spool directory is the one exception: those are group-writable and sticky by design, which is what makes them safe, so they’re accepted as they ship.
  • No ${...} substitution happens on cron text. Cron doesn’t expand it, so neither do we; 0 3 * * * dump.sh ${DB} reaches the shell exactly as written.
  • Overlap with include is an error. A file is either RunWisp TOML or a crontab; RunWisp won’t guess which.
  • anacron isn’t read. /etc/anacrontab and its timestamps are untouched, so on a box where anacron (not cron) is what really runs cron.daily, those jobs keep their existing behaviour.

Everything else about a cron-sourced task is ordinary: [defaults] applies, notifications fire, runwisp exec triggers it, and the reload diff treats it exactly like a hand-written one.

CLI flags: config, data directory & listen address

Section titled “CLI flags: config, data directory & listen address”

These flags decide which config file gets read, where state lives, and where the HTTP/Web UI listens. They apply to every runwisp subcommand (daemon, tui, exec, and so on):

Flag Default What it does
--config, -c runwisp.toml Path to the TOML config file, resolved against the working directory. Also reads RUNWISP_CONFIG.
--data .runwisp Directory for all persistent state — SQLite database, per-task logs, PID file, and the local Unix socket. Also reads RUNWISP_DATA.
--socket <data>/runwisp.sock Path to the control socket. The daemon binds it; every CLI subcommand connects to it. Also RUNWISP_SOCKET. See Control socket.
--host 127.0.0.1 Bind address for the HTTP server. Use 0.0.0.0 to listen on every interface. Also reads RUNWISP_HOST.
--port 9477 TCP port for the HTTP server (REST API, SSE log stream, Web UI). Also reads RUNWISP_PORT.
--log-level info Log verbosity — debug, info, warn, error. Also reads RUNWISP_LOG_LEVEL.
--log-format auto Log shape — auto, text, json. Also reads RUNWISP_LOG_FORMAT.

For every flag above, an explicit CLI flag always wins over its env var — the env var only fills in when the flag is left at its default. That makes them safe to bake into a container image’s ENV as defaults that extra arguments on docker run can still override, which is exactly what the official image does. (In a container, prefer overriding the env var: --entrypoint skips the image’s startup checks, and a --data flag is invisible to its healthcheck.)

Terminal window
runwisp daemon --data /var/lib/runwisp --host 0.0.0.0 --port 9477

--log-level and --log-format shape the daemon’s own log output — Operations: Logging covers what each value does. For the complete list of runwisp subcommands and flags in one place, see the CLI reference.

It’s worth picking --data once and sticking with it. The database file (runwisp.db), the local Unix socket (runwisp.sock), and every task’s logs all live under that directory, so relocating later is a plain directory move — not a config change.

The daemon never persists the password or the JWT signing key. The password comes from RUNWISP_PASSWORD if you set it; otherwise a fresh ephemeral one is minted every boot. The JWT key is derived deterministically from the password, so setting RUNWISP_PASSWORD (via a Docker secret or systemd LoadCredential=, say) is what keeps browser sessions alive across restarts. Auth has the full story.

Variable What it does
RUNWISP_PASSWORD Sets the daemon password in memory. When unset, a fresh ephemeral password is minted every boot (and every session rotates with it).
RUNWISP_NO_AUTH 1 or true disables authentication entirely — local dev / trusted networks only. Mutually exclusive with RUNWISP_PASSWORD. See Auth.
RUNWISP_TRUSTED_PROXIES Comma-separated CIDR list of reverse proxies whose X-Forwarded-* headers the daemon may honor.
RUNWISP_CLOUD_TOKEN Used by runwisp cloud to connect to a control plane. Ignored in standalone mode.
RUNWISP_SOCKET Control socket path. Same effect as --socket; the flag wins when both are set.
RUNWISP_CONFIG Config file path. Same effect as --config; the flag wins when both are set.
RUNWISP_DATA Data directory. Same effect as --data; the flag wins when both are set.
RUNWISP_HOST HTTP bind address. Same effect as --host; the flag wins when both are set.
RUNWISP_PORT HTTP port. Same effect as --port; the flag wins when both are set. An unparseable or out-of-range value is a startup error.
RUNWISP_TLS Overrides [daemon] tlsauto or off. Applied on every config load, including reload, so it never disagrees with itself and never trips the reload gate.
RUNWISP_DEBUG_ADDR Opt-in. A loopback address (e.g. 127.0.0.1:6060) on which to serve Go pprof memory/CPU profiles. Off by default; a non-loopback address is refused so profiles never reach the network.

On startup the daemon creates a Unix socket — <datadir>/runwisp.sock by default. Local CLI commands and the TUI talk to the daemon over this socket without ever needing a password — access is gated by the data dir’s 0700 mode, the socket’s own 0600 mode, and a SO_PEERCRED check when a connection is accepted. On a graceful shutdown, the socket file is cleaned up.

You can move the socket off the data dir with --socket (or RUNWISP_SOCKET). Two cases where that’s the difference between working and not:

  • Bind-mounted data dir. Some filesystems — Docker Desktop’s osxfs/virtiofs bind mounts, a few network filesystems — let the daemon bind a socket but reject the chmod that locks it to 0600. RunWisp tolerates that (it warns and keeps serving, since the 0700 data dir and the SO_PEERCRED check still gate access), but if you’d rather avoid it entirely, point --socket at a Linux-native path like /run/runwisp.sock and keep the database and logs on the bind mount.
  • Reaching a non-default daemon from the CLI. Because every subcommand connects to --socket, you can talk to a daemon by its socket alone — runwisp status --socket /run/runwisp.sock — without restating the --data directory it was started with.

The daemon and the CLI must agree on the path: whatever you pass to runwisp daemon --socket …, pass the same to runwisp status, runwisp exec, and friends (or set RUNWISP_SOCKET once in the environment they share).