Skip to content

[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.

Terminal window
runwisp validate # validate runwisp.toml and print a summary, or a structured error
runwisp schema # print the full runwisp.toml JSON Schema (draft 2020-12) to stdout

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 key
run = "..."

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 disk
  • pg_dump app | gzip > /backups/app.sql.gz: a shell pipeline
  • make -C /srv/app deploy: any command on your PATH

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"

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 = false

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.

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 :30
run = "curl -fsS http://localhost:8080/healthz"

Possible values:

  • */5 * * * *: every 5 minutes
  • 0 3 * * *: every day at 03:00
  • 0 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.

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 = true
run = "/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 startup
  • true or "daemon": run every time the daemon starts, including after runwisp restart and 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.

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 the N most 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 = 24
on_overlap = "queue"

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 busy

Set it in [defaults] to apply it to every cron task.

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"

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 by max_queued)
  • skip: the new run is dropped and recorded as skipped
  • kill: the running run is stopped so the new one takes over

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 = 4

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 = 500

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"

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 = 3
retry_delay = "2s"

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 = 3
retry_delay = "5s"

type: enum default: "constant"

How retry_delay grows across attempts.

[tasks.flaky-fetch]
retry_attempts = 4
retry_delay = "2s"
retry_backoff = "exponential" # 2s, 4s, 8s, 16s

Possible values:

  • constant: the same retry_delay before every attempt (2s, 2s, 2s, 2s)
  • linear: retry_delay times 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.

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.

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

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.

  • -u and -o pipefail are not set. For pipefail, set shell to /bin/bash and add set -o pipefail to the script.
  • To turn fail-fast off for a task, start run with set +e.
  • For one command that may exit non-zero, add || true.

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 signal
  • timeout: stopped for running longer than timeout
  • crashed: still running when the daemon itself was killed (recorded at the next start)
  • log_overflow: hit log_max_size under log_on_full = "kill"
  • start_failed: a service gave up after too many fast restarts (see restart_attempts)
  • unhealthy: a service instance was stopped for failing its health_check
  • missed: a scheduled firing was skipped while the daemon was down
  • stopped: an operator stopped the run
  • daemon_stopped: still running when the daemon shut down
  • skipped: dropped because a run was already going under on_overlap = "skip"
  • queue_full: dropped because the queue already held max_queued runs
  • dst_skipped: the second of two identical firings when the clock falls back for DST
  • output:<regex>: a line of stdout or stderr matched the regex, even if the command exited 0. See 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 secrets are 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 \b into 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.
  • \b is a word edge, \d or [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|b matches 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.

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 = 200

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.

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"

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 oldest
  • drop_new: keep the first output, ignore the rest
  • kill: stop the run and record log_overflow

env and secrets reach the process the same way. The difference is who can see them:

  • env and env_file values are shown in the Web UI, TUI, and REST API to anyone logged in.
  • secrets and secrets_file values are never shown. At most, the secrets_file path 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):

  1. The daemon’s own environment (see env_base).
  2. env_file, then env: first from [defaults], then from the task itself.
  3. secrets_file, then secrets: 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 the runwisp.toml directory.
  • Format is dotenv: one KEY=VALUE per 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"

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}"

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"

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.

For an introduction, see Parameters.

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 an env or secrets key 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 no choices or type.

Attributes:

  • default: the value scheduled runs use and the manual form starts with (true/false for a flag).
  • required: the manual form won’t submit without a value (env, arg, option). A required parameter on a scheduled task must also set default.
  • type: string (default) or number. 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: with choices, also allow a value not in the list. Rejected without choices.
  • description: help text shown under the field.

type: string

The directory the process runs in. Unset, it is the daemon’s directory.

  • A relative path is relative to the runwisp.toml directory.
  • ~ 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"

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 only PATH, SHELL, HOME, and USER/LOGNAME (like cron), so runs are repeatable. Set everything else in env.

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"

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"

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"

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"

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 your run command inside the service’s running container (docker compose exec). Requires run.
  • run: start a new container for each run (docker compose run --rm). It runs your run command 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.*].

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]"). See notifiers for 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]].

The config fails to load with any of these: