[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 = falsemetrics_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. |
shutdown_timeout
Section titled “shutdown_timeout”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
Section titled “external_url”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.
tls, tls_cert, tls_key
Section titled “tls, tls_cert, tls_key”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 withtls = "auto". There’s nothing on the wire to eavesdrop, andcurl http://localhost:9477just works for local dev. - Non-loopback (
--host 0.0.0.0, a LAN IP, …) withtls = "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
Section titled “metrics_enabled”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.
metrics_listen
Section titled “metrics_listen”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.
include
Section titled “include”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.
[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’sworking_dir,env_file,compose_file,${file:...}) inside an included file — those resolve against that file’s directory, not the root’s. So aconf.d/backups.tomlreferencingenv_file = "backup.env"looks forconf.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_cron
Section titled “include_cron”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:
[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.
Which files, and who the jobs run as
Section titled “Which files, and who the jobs run as”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.
Globs read what cron would read
Section titled “Globs read what cron would read”A glob only picks up the files cron itself would schedule, and the rule depends on which kind of directory it’s globbing:
/etc/crontaband anycron.d/*— regular files whose names are made of letters, digits,-and_. That’s how/etc/cron.d/backup.dpkg-old, a hand-renamedjob.disabled, and aREADMEstay 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, sojohn.doeor a$-suffixed service account is a perfectly normal crontab there. The one thing excluded on purpose istmp.*, which is the temp filecrontab -ewrites 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.
Everything else worth knowing
Section titled “Everything else worth knowing”- 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:lineat boot, byrunwisp reload,runwisp statusandrunwisp validate, so you know exactly which jobs aren’t running.runwisp import cronon 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_cronfile still loads. A spool crontab for auserdel’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 — makesos/userread 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 asendmailnotifier — it needs no configuration on a box that was already mailing cron output — and the warning stops. ASHELL=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.
promotethe 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— andpromoteplusmax_concurrentgets 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
includeis an error. A file is either RunWisp TOML or a crontab; RunWisp won’t guess which. - anacron isn’t read.
/etc/anacrontaband its timestamps are untouched, so on a box where anacron (not cron) is what really runscron.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.)
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.
Environment variables
Section titled “Environment variables”| 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] tls — auto 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. |
Control socket
Section titled “Control socket”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
chmodthat locks it to0600. RunWisp tolerates that (it warns and keeps serving, since the0700data dir and theSO_PEERCREDcheck still gate access), but if you’d rather avoid it entirely, point--socketat a Linux-native path like/run/runwisp.sockand 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--datadirectory 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).