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 = "auto"
tls_cert = ""
tls_key = ""
metrics_enabled = false
metrics_listen = ""
include = ["conf.d/*.toml"]
KeyDefaultWhat 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_urlunsetPublic 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"auto""auto" serves HTTPS automatically whenever the bind address is non-loopback (self-signing a cert on first boot); "off" always serves plain HTTP. Loopback binds stay HTTP either way. Ignored when tls_cert/tls_key are set.
tls_certunsetPath 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_keyunsetPath to the PEM private key matching tls_cert. Both keys are set together or not at all.
metrics_enabledfalseMaster 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_listenunsetOptional 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.
includeunsetGlob 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.

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.

RunWisp serves HTTPS by itself, with no certificate to obtain and nothing to wire up. The rule is simple: the moment you bind somewhere other than loopback, the channel is encrypted.

  • Loopback (--host 127.0.0.1, the default): plain HTTP. 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.

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.

Turn it off with tls = "off" 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_TRUST_PROXY 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.

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):

FlagDefaultWhat it does
--config, -crunwisp.tomlPath to the TOML config file, resolved against the working directory.
--data.runwispDirectory for all persistent state — SQLite database, per-task logs, PID file, and the local Unix socket.
--socket<data>/runwisp.sockPath to the control socket. The daemon binds it; every CLI subcommand connects to it. Also RUNWISP_SOCKET. See Control socket.
--host127.0.0.1Bind address for the HTTP server. Use 0.0.0.0 to listen on every interface.
--port9477TCP port for the HTTP server (REST API, SSE log stream, Web UI).
--log-levelinfoLog verbosity — debug, info, warn, error. Also reads RUNWISP_LOG_LEVEL.
--log-formatautoLog shape — auto, text, json. Also reads RUNWISP_LOG_FORMAT.
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.

VariableWhat it does
RUNWISP_PASSWORDSets the daemon password in memory. When unset, a fresh ephemeral password is minted every boot (and every session rotates with it).
RUNWISP_NO_AUTH1 or true disables authentication entirely — local dev / trusted networks only. Mutually exclusive with RUNWISP_PASSWORD. See Auth.
RUNWISP_TRUST_PROXYComma-separated CIDR list of reverse proxies whose X-Forwarded-* headers the daemon may honor.
RUNWISP_CLOUD_TOKENUsed by runwisp cloud to connect to a control plane. Ignored in standalone mode.
RUNWISP_SOCKETControl socket path. Same effect as --socket; the flag wins when both are set.
RUNWISP_DEBUG_ADDROpt-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).