[tasks.*]
Every key a [tasks.<name>] block accepts, with its type, default, and accepted
values. Unset keys fall back to [defaults], then a
built-in default.
runwisp validate # validate runwisp.toml and print a summary, or a structured errorrunwisp schema # print the full runwisp.toml JSON Schema (draft 2020-12) to stdoutIdentity & metadata
Section titled “Identity & metadata”[tasks.<name>]
Section titled “[tasks.<name>]”type: string required
The text after tasks. is the task’s name: [tasks.backup-db] is a task
called backup-db. The name is used in the Web UI, TUI, API, CLI, and log
directory.
- Letters, digits, and
. _ - :, up to 100 characters. - Must be unique across
[tasks.*]and[services.*]. - A name with
.or:must be quoted ([tasks."db:backup"]).
[tasks.backup-db]run = "pg_dump app | gzip > /backups/app.sql.gz"
[tasks."db:backup"] # quotes required: not a bare TOML keyrun = "..."type: string required (unless compose_file is set)
The shell command to run. It runs as /bin/sh -e -c <command>, so the first
failing command stops the run and its exit code becomes the run’s exit code
(see Fail-fast). Set shell to
bash for arrays or pipefail.
With compose_file, run is optional: it runs your command in
the service’s container (see compose_mode).
[tasks.backup-db]run = "pg_dump app | gzip > /backups/app.sql.gz"Examples:
/usr/local/bin/backup.sh: a script on diskpg_dump app | gzip > /backups/app.sql.gz: a shell pipelinemake -C /srv/app deploy: any command on yourPATH
description
Section titled “description”type: string
A short text shown in the Web UI and TUI. It doesn’t change how anything runs.
[tasks.backup-db]description = "Nightly Postgres dump to S3"type: string default: "Tasks"
The sidebar section it is listed under in the Web UI and TUI. Entries with the same group are listed together.
[tasks.weekly-report]group = "Reports"manual_trigger
Section titled “manual_trigger”type: boolean default: true
Whether operators can start, stop, or restart it by hand, and
pause its schedule. Set false to
lock it to its schedule (or, on a service, to supervisor-only restarts):
runwisp start/stop/restart/pause and the REST API return 403, the Web UI
hides its controls, the station trigger is refused.
Hooks still work.
[tasks.backup-db]manual_trigger = falsehook_tokens
Section titled “hook_tokens”type: array of strings or tables
Tokens that let CI or a webhook control this unit over a
hook without the dashboard password. Each token works
for this unit only. Load them with ${VAR} or ${file:...} so they stay out of
the file. Tokens are never shown in the API or Web UI.
[tasks.deploy]run = "./deploy.sh"hook_tokens = [ "${CI_DEPLOY_TOKEN}", # every action { token = "${ROLLBACK_TOKEN}", allow = ["stop"] }, # stop only]A plain string allows every action. A table with allow limits the token to
the listed actions: run, start, stop, restart (services have no run).
Each token needs at least 32 characters and no whitespace. Generate one with
openssl rand -hex 32. List more than one to rotate without downtime, or to
give each caller its own. manual_trigger doesn’t apply to
hooks.
Scheduling
Section titled “Scheduling”type: string
When the task runs, as a cron expression. Without it, the task runs only when
started by hand. Accepts 5 fields (min hour day month weekday), 6 fields with
a leading seconds field, or an @ shorthand.
[tasks.healthcheck]cron = "*/30 * * * * *" # every 30s, aligned to :00 and :30run = "curl -fsS http://localhost:8080/healthz"Possible values:
*/5 * * * *: every 5 minutes0 3 * * *: every day at 03:000 9 * * 1-5: 09:00 Monday to Friday*/30 * * * * *: every 30 seconds (6-field, leading seconds)@hourly,@daily: shorthand forms@every 1h30m: fixed interval, ignores wall-clock alignment
How scheduling works has the full cron grammar, DST
rules, and catch-up details. runwisp validate runs the same checks as daemon
startup, so it catches a bad cron expression before you deploy.
run_on_start
Section titled “run_on_start”type: boolean or enum default: false
Run the task once at startup. This is separate from cron and
catch_up.
[tasks.warm-cache]run_on_start = truerun = "/usr/local/bin/warm-cache"
[tasks.start-tunnel]run_on_start = "boot"run = "/usr/local/bin/start-tunnel"Possible values:
false: don’t run at startuptrueor"daemon": run every time the daemon starts, including afterrunwisp restartand updates"boot": run once per machine boot, like cron’s@reboot. Restarting the daemon doesn’t run it again until the next boot. In a container, each container start counts as a boot.
On Linux, RunWisp tells boots apart by the kernel boot ID and the start time
of PID 1. On macOS it uses the boot session UUID. Where neither is available,
"boot" runs on every daemon start, like true. A container that shares the
host’s PID namespace (--pid=host) follows the host’s boots.
catch_up
Section titled “catch_up”type: integer default: 1 min: 0 max: 10000
How many runs missed while the daemon was down are run at startup.
0: run none. Good for jobs that run every minute.1: run only the most recent missed one. Good for a daily sync.N: run up to theNmost recent. Good for queue processors.
A value above 1 requires on_overlap = "queue", because the missed runs start
one after another.
[tasks.hourly-roll]cron = "0 * * * *"catch_up = 24on_overlap = "queue"jitter
Section titled “jitter”type: duration max: 24h
The longest this task’s start may be delayed so that jittered tasks with the same start time (like 03:00) run one after another instead of all at once. It is a maximum, not a fixed delay: with nothing else jittered running, the task starts on time.
- Doesn’t apply to manual runs, retries, or catch-up runs.
- Needs
cron.
[tasks.nightly-report]cron = "0 3 * * *"jitter = "30m" # start may slip up to 30m while other jittered runs are busySet it in [defaults] to apply it to every cron
task.
timezone
Section titled “timezone”type: string default: [daemon] timezone, then the host zone
The IANA zone this task’s cron is read in. Follows daylight saving
time.
[tasks.eu-market-open]cron = "0 9 * * 1-5"timezone = "Europe/Bratislava"Concurrency
Section titled “Concurrency”on_overlap
Section titled “on_overlap”type: enum default: "queue"
What happens when the task is triggered while already at
max_concurrent (default 1).
[tasks.queue-drain]cron = "*/10 * * * *"on_overlap = "skip"Possible values:
queue: the new run waits and starts when a slot frees up (queue size bounded bymax_queued)skip: the new run is dropped and recorded as skippedkill: the running run is stopped so the new one takes over
max_concurrent
Section titled “max_concurrent”type: integer default: 1 min: 1 max: 1024
How many runs of this task may be active at once. At the limit,
on_overlap decides what happens to the next trigger.
[tasks.thumbnailer]max_concurrent = 4max_queued
Section titled “max_queued”type: integer default: 100 min: 0 max: 10000
With on_overlap = "queue", the cap on runs waiting in line. Past the cap,
further triggers record end_reason = "queue_full".
[tasks.ingest]on_overlap = "queue"max_queued = 500Retries & timeout
Section titled “Retries & timeout”timeout
Section titled “timeout”type: duration default: none (no limit)
The longest one attempt may run. A longer run is stopped (with
stop_signal, then graceful_stop) and
recorded as timeout. "0s" turns off a [defaults] timeout.
[tasks.queue-drain]cron = "*/10 * * * *"timeout = "9m"retry_attempts
Section titled “retry_attempts”type: integer default: 0 min: 0 max: 100
Extra attempts after the first one fails. A retry happens only when the reason
is failed, timeout, crashed, log_overflow, start_failed, or
unhealthy, and
failures counts the run as a failure. A manual stop or a kill ends
the retries.
[tasks.flaky-fetch]retry_attempts = 3retry_delay = "2s"retry_delay
Section titled “retry_delay”type: duration default: 5s
The wait before the first retry. Used only when
retry_attempts is above 0;
retry_backoff decides how it grows.
[tasks.flaky-fetch]retry_attempts = 3retry_delay = "5s"retry_backoff
Section titled “retry_backoff”type: enum default: "constant"
How retry_delay grows across attempts.
[tasks.flaky-fetch]retry_attempts = 4retry_delay = "2s"retry_backoff = "exponential" # 2s, 4s, 8s, 16sPossible values:
constant: the sameretry_delaybefore every attempt (2s, 2s, 2s, 2s)linear:retry_delaytimes the attempt number (2s, 4s, 6s, 8s)exponential: double each time (2s, 4s, 8s, 16s)
The computed delay is capped at 5m, regardless of retry_backoff.
graceful_stop
Section titled “graceful_stop”type: duration default: 5s
How long a stopping run has to exit after stop_signal before
RunWisp SIGKILLs it. Used on timeout, on_overlap = "kill", manual stop, and
daemon shutdown. "0s" kills at once.
[tasks.queue-drain]graceful_stop = "30s"- The signal goes to the whole process group. Every child process gets the same time, and anything still running after it is killed.
- If it is longer than
shutdown_timeout, the daemon warns at boot, because at shutdown the run may be killed early.
stop_signal
Section titled “stop_signal”type: enum default: "SIGTERM"
The first signal sent to stop a run. The SIG prefix is optional. Use
SIGINT for tools that expect Ctrl-C, or SIGKILL to skip the grace time.
Anything still running after graceful_stop is SIGKILLed.
[tasks.queue-drain]stop_signal = "SIGINT"graceful_stop = "10s"Possible values: SIGTERM, SIGINT, SIGQUIT, SIGHUP, SIGKILL, SIGUSR1,
SIGUSR2 (also without the SIG prefix).
Fail-fast
Section titled “Fail-fast”RunWisp starts run as <shell> -e -c <script>. A multi-line script
stops at the first command that fails, and that exit code becomes the run’s
exit code. You don’t need set -e.
-uand-o pipefailare not set. Forpipefail, setshellto/bin/bashand addset -o pipefailto the script.- To turn fail-fast off for a task, start
runwithset +e. - For one command that may exit non-zero, add
|| true.
What counts as a failure
Section titled “What counts as a failure”failures
Section titled “failures”type: string[] default: ["failed", "timeout", "crashed", "log_overflow", "start_failed", "unhealthy", "missed"]
Which run outcomes count as a failure.
[tasks.web]failures = ["-missed"] # stop paging on a missed tick
[tasks.rsync]failures = ["timeout", "23", "24"] # only these exits fail
[tasks.report]failures = ['+output:(?i)error'] # also fail when a line says "error"Possible values:
failed: the command exited non-zero or was killed by a signaltimeout: stopped for running longer thantimeoutcrashed: still running when the daemon itself was killed (recorded at the next start)log_overflow: hitlog_max_sizeunderlog_on_full = "kill"start_failed: a service gave up after too many fast restarts (seerestart_attempts)unhealthy: a service instance was stopped for failing itshealth_checkmissed: a scheduled firing was skipped while the daemon was downstopped: an operator stopped the rundaemon_stopped: still running when the daemon shut downskipped: dropped because a run was already going underon_overlap = "skip"queue_full: dropped because the queue already heldmax_queuedrunsdst_skipped: the second of two identical firings when the clock falls back for DSToutput:<regex>: a line of stdout or stderr matched the regex, even if the command exited 0. See Output patterns.
Output patterns
Section titled “Output patterns”Some commands print an error and still exit 0. Add an output: token to catch
that:
[tasks.report]run = "./report.sh"failures = ['+output:ERROR']- Each line of stdout and stderr is checked on its own. The pattern can match anywhere in the line.
- The first match ends the run as
failed. The log shows which pattern matched. - Lines are checked after
secretsare masked, so they look the same as in the log. - Wrap the token in single quotes. Inside double quotes, TOML reads
\as an escape:"output:\d"is a config error, and"output:\bfail"quietly turns\binto a backspace character.
Add several tokens to fail on any of them:
failures = ['+output:(?i)\berror\b', '+output:^Traceback']Patterns use Go’s RE2 syntax, which
is close to the regex in most languages. Lookarounds ((?=...)) and
backreferences (\1) are not supported. Common patterns:
| Token | Fails when a line |
|---|---|
'output:ERROR' |
contains ERROR, in upper case only |
'output:(?i)error' |
contains error in any case (Error, ERROR) |
'output:\bfail\b' |
contains the word fail, but not failover |
'output:^FATAL' |
starts with FATAL |
'output:failed$' |
ends with failed |
'output:error|warning' |
contains error or warning |
'output:\[ERROR\]' |
contains [ERROR] (\ makes [ and ] plain text) |
'output:[1-9][0-9]* errors?' |
reports one or more errors (3 errors), not 0 errors |
'output:HTTP 5[0-9]{2}' |
contains a 5xx status such as HTTP 503 |
'output:(?i)(exception|panic)' |
contains exception or panic in any case |
The building blocks:
(?i)at the start ignores case for the whole pattern.^is the start of the line,$is the end.\bis a word edge,\dor[0-9]is a digit,.is any character.*repeats the previous item zero or more times,+one or more,?makes it optional,{2}exactly twice.a|bmatches either side. Use(...)to group.- To match
. [ ] ( ) * + ? | ^ $ \as plain text, put\before it.
To drop a pattern inherited from [defaults], write
the same token with -, for example '-output:(?i)error'. The text must match
exactly.
Logs & retention
Section titled “Logs & retention”keep_runs
Section titled “keep_runs”type: integer min: 0 max: 1000000
Keep only the N most recent completed runs. Older runs and their logs are
deleted by the hourly cleanup. 0 keeps no completed runs; unset means no
count limit. With
keep_for also set, whichever deletes more wins.
[tasks.backup-db]keep_runs = 200keep_for
Section titled “keep_for”type: duration
Delete runs older than this. With keep_runs also set, whichever
deletes more wins.
[tasks.backup-db]keep_for = "90d"Examples: 48h (2 days), 14d, 2w, 90d.
Every duration accepts ms, s, m, h, d (days), and w (weeks), for
example "90s", "2d", "1w". See Logs & retention for log
rotation and cleanup timing.
log_max_size
Section titled “log_max_size”type: size default: 100mb
The largest size of one run’s captured output on disk. Units: b, kb, mb,
gb, tb. At the limit, log_on_full decides what happens.
0, negative, and invalid values are rejected at load.
[tasks.backup-db]log_max_size = "500mb"log_on_full
Section titled “log_on_full”type: enum default: "drop_old"
What happens when a run’s log reaches log_max_size.
[tasks.backup-db]log_max_size = "50mb"log_on_full = "drop_old"Possible values:
drop_old: keep the newest output, delete the oldestdrop_new: keep the first output, ignore the restkill: stop the run and recordlog_overflow
Environment & secrets
Section titled “Environment & secrets”env and secrets reach the process the same way. The
difference is who can see them:
envandenv_filevalues are shown in the Web UI, TUI, and REST API to anyone logged in.secretsandsecrets_filevalues are never shown. At most, thesecrets_filepath is shown.- If a command prints a secret, RunWisp replaces the exact value with
[redacted]in the stored and streamed output. This only matches the exact text: a secret that was changed (base64, URL-encoded) or split across lines can still appear.
Merge order at start (a later layer wins on the same key):
- The daemon’s own environment (see
env_base). env_file, thenenv: first from[defaults], then from the task itself.secrets_file, thensecrets: first from[defaults], then from the task itself.
So an inline value beats a file value, and a secret beats an env value with the same name.
Files:
- Paths can be absolute,
~/-relative, or relative to therunwisp.tomldirectory. - Format is dotenv: one
KEY=VALUEper line,#starts a comment, blank lines are skipped. Values are read as they are: no shell expansion and no${...}substitution.
Limits, checked at load:
- Keys must match
^[A-Za-z_][A-Za-z0-9_]*$. - Values can’t contain NUL bytes or be longer than 32 KiB.
- At most 256 env + secrets entries in total.
type: map
Environment variables for the process. Their values are shown in the API and
UI, so put passwords and tokens in secrets. To read from a file,
use env_file.
[tasks.backup.env]BACKUP_BUCKET = "s3://prod-backups"DRY_RUN = "0"secrets
Section titled “secrets”type: map
Secret environment variables: passwords, tokens, API keys. They work like
env, but their keys and values are never shown. To read from a file,
use secrets_file.
[tasks.backup.secrets]RESTIC_PASSWORD = "${file:~/.config/runwisp/restic.pass}"env_file
Section titled “env_file”type: string
A dotenv file merged below env (inline values win). Its values are
shown in the API and UI, like env. Paths can be absolute, ~/-relative, or
relative to the runwisp.toml directory. Lines are plain KEY=VALUE, with no
shell expansion.
[tasks.backup]env_file = "backup.env"secrets_file
Section titled “secrets_file”type: string
A dotenv file merged below secrets. Only the path is shown in the
API and UI, never the contents. Same paths and format as
env_file.
[tasks.backup]secrets_file = "/etc/runwisp/backup-secrets.env"env and secrets are for values that are the same on every
run. For a value that changes per run, use a parameter.
Parameters
Section titled “Parameters”For an introduction, see Parameters.
params
Section titled “params”type: table (array of [[tasks.<name>.params]] entries)
The inputs a task takes. Each one becomes a field on the Run form in the Web
UI, TUI, and REST API. Each entry has exactly one kind keyword (env, arg,
option, or flag) that decides how the value reaches your command.
- Values are passed as real arguments or env vars, never pasted into the shell
string, so input like
; rm -rf /is harmless. - Scheduled runs (cron, catch-up, retries) use the declared defaults.
[tasks.backup]run = "/usr/local/bin/backup.sh"params = [ { env = "PROJECT_ID", required = true }, { arg = "source", required = true }, { arg = "dest", default = "/backups" }, { option = "--region", choices = ["us", "eu"] }, { flag = "--force" },]Kind keyword (exactly one per entry):
env: pass the value as an environment variable with this name. The name can’t also be anenvorsecretskey on the same task.arg: append the value as a positional argument, in declaration order.option: render as--name value. A name ending in=glues them (--date=2026-01-01).flag: a boolean whose token is appended when on, omitted when off. Takes nochoicesortype.
Attributes:
default: the value scheduled runs use and the manual form starts with (true/falsefor a flag).required: the manual form won’t submit without a value (env,arg,option). A required parameter on a scheduled task must also setdefault.type:string(default) ornumber. A value that isn’t a number is rejected before the run starts.choices: a fixed list, shown as a dropdown. Other values are rejected.allow_custom: withchoices, also allow a value not in the list. Rejected withoutchoices.description: help text shown under the field.
Working directory & shell
Section titled “Working directory & shell”working_dir
Section titled “working_dir”type: string
The directory the process runs in. Unset, it is the daemon’s directory.
- A relative path is relative to the
runwisp.tomldirectory. ~is the home directory of the user the run runs as.- The directory is checked when the run starts, not at load.
[tasks.deploy]working_dir = "/srv/app"run = "./bin/build"type: string default: /bin/sh
The shell for run, as an absolute path. Use
/bin/bash for arrays, [[ ]], or set -o pipefail. It is started as
<shell> -e -c <script>, so fail-fast works
for POSIX shells. RunWisp warns at boot when it can’t turn fail-fast on.
Host processes only: it is an error on a compose unit.
[tasks.nightly-report]shell = "/bin/bash"run = "set -o pipefail; make report | tee out.log"env_base
Section titled “env_base”type: enum default: "inherit"
What the environment starts from, before env and
secrets are added. Host processes only: it is an error on a
compose unit.
[tasks.backup-db]env_base = "clean"run = "/usr/local/bin/nightly"Possible values:
inherit: start from the daemon’s environment. Runs then depend on how the daemon was started.clean: start with onlyPATH,SHELL,HOME, andUSER/LOGNAME(like cron), so runs are repeatable. Set everything else inenv.
type: string
Run as another OS user: user or user:group, by name or numeric id (the same
form as chown and Compose). RunWisp looks up the account at start, sets
HOME, USER, and LOGNAME, and applies its groups.
- Works only when the daemon runs as root. Otherwise the run fails.
- Empty keeps the daemon’s user.
- Host processes only: it is an error on a compose unit.
[tasks.backup-db]user = "reporter:reporter"type: string
The octal file-creation mask for the run: "027" makes new files rw-r-----.
- Write at least three digits (
"022", not"22"). - It applies only to this run’s process, not to other runs.
- Empty keeps the daemon’s umask.
- Host processes only: it is an error on a compose unit.
[tasks.backup-db]umask = "027"Compose-backed tasks
Section titled “Compose-backed tasks”A task can run a compose service instead of a shell command:
[tasks.nightly-backup]cron = "0 3 * * *"compose_file = "./docker-compose.yml"compose_service = "backup"compose_file
Section titled “compose_file”type: string
Path to a docker-compose file. The unit then runs the compose service named by
compose_service instead of a shell command. Without
run, each run is docker compose run --rm <service> with the
service’s own command. With run, see compose_mode.
[tasks.nightly-backup]cron = "0 3 * * *"compose_file = "./docker-compose.yml"compose_service = "backup"compose_service
Section titled “compose_service”type: string default: the task name
The service in compose_file to run. Requires
compose_file.
[tasks.nightly-backup]compose_file = "./docker-compose.yml"compose_service = "backup"compose_mode
Section titled “compose_mode”type: enum default: "exec" when run is set, else "run"
How a compose_file unit runs.
[tasks.migrate]compose_file = "./docker-compose.yml"compose_service = "app"compose_mode = "exec"run = "rails db:migrate"Possible values:
exec: run yourruncommand inside the service’s running container (docker compose exec). Requiresrun.run: start a new container for each run (docker compose run --rm). It runs yourruncommand if set, else the service’s own command.
See Running a command in a service
for the limits of exec.
To import every service in a compose file, see
[compose.*].
Notifications
Section titled “Notifications”notify
Section titled “notify”type: string[]
Notifier ids to alert when a run counts as a failure (see
failures). A missed run is a failure by
default, so it alerts here too.
[tasks.backup-db]notify = ["slack-ops"]- Each entry is a notifier declared in
[notifiers.<id>],"inapp"(the bell), or"<id>:<target>"to reuse a notifier’s credentials with another target ("slack:#deploys","tg:-1009998887","mail:[email protected]"). Seenotifiersfor which types accept a target. [notify] global_notifiers(default["inapp"]) is added to every list.notify = []is the same as leaving it out.- To alert on other outcomes (a success, for example), use a
[[route]].
What’s rejected on tasks
Section titled “What’s rejected on tasks”The config fails to load with any of these:
- Service-only keys:
restart,restart_attempts,restart_delay,restart_backoff,healthy_after,instances,priority,autostart,depends_on. A task usesretry_attemptsinstead of restarts. - A name already used by a
[services.*]entry. - An empty or missing
run(unlesscompose_fileis set).