Docker tasks
This page is about tasks whose run command uses docker. To supervise the
services in an existing docker-compose.yml, see
From docker-compose. To run RunWisp itself in a
container, see Running in Docker.
Two flags to get right
Section titled “Two flags to get right”Always use --rm. Without it, every run leaves a stopped container behind.
An hourly task leaves about 8,700 a year. The Docker daemon does the cleanup,
so it still happens if RunWisp kills the client on timeout.
Never use -d. A detached docker run prints a container ID and exits 0
at once. RunWisp records a successful 40 ms run, and the real job’s output,
exit code, and duration are lost. Stay attached: the container’s stdout and
stderr go into the run log, and its exit code becomes the run’s exit code.
A one-shot container
Section titled “A one-shot container”[tasks.crunch-numbers]group = "Analytics"description = "Nightly aggregation of yesterday's events"cron = "0 4 * * *"on_overlap = "skip"timeout = "2h"notify = ["slack-ops"]
run = """docker run --rm --init \\ --network=internal \\ --memory=2g --cpus=1.5 \\ --env-file=/etc/analytics/config.env \\ ghcr.io/example/analytics:current \\ --date=$(date -u -d 'yesterday' +%Y-%m-%d)"""--memory/--cpus: a runaway job gets killed inside its container and shows up as a failed run, instead of the host’s OOM killer picking a random process.--init: runs a small init as PID 1 that forwards signals and reaps zombie processes. Many images ignore SIGTERM without it, so atimeoutends in a SIGKILL instead of a clean stop.--network: give the container only the network it needs.internalis an example name fromdocker network create.--env-file, not-e KEY=value: values on the command line show up inpsand in the run log.
Pass secrets by name
Section titled “Pass secrets by name”Keep the values in RunWisp’s secrets or
secrets_file, and pass them with -e
and no value:
[tasks.crunch-numbers]secrets_file = "/etc/runwisp/analytics-secrets.env"
run = """docker run --rm --init \\ -e DB_PASSWORD -e API_TOKEN \\ ghcr.io/example/analytics:current"""-e NAME copies the variable from the task’s environment, so the value is
never on the command line. RunWisp also hides secret values in the run log;
see secrets for the limits.
Timeouts and orphaned containers
Section titled “Timeouts and orphaned containers”On timeout, RunWisp sends stop_signal
to the task, waits graceful_stop, then
sends SIGKILL. The attached docker run client passes the first signal into
the container. If the image ignores it, the client is killed and the container
keeps running on its own.
To guard against that, name the container and remove any old one first:
NAME=crunch-numbersdocker rm -f "$NAME" >/dev/null 2>&1 || truedocker run --rm --init --name "$NAME" ghcr.io/example/analytics:currentWith on_overlap = "skip", two copies can never run at once.
Run a command in an existing container
Section titled “Run a command in an existing container”If the work belongs to an app that already runs in a container (an artisan
command, a manage.py job, psql against your database), run it there
instead of installing the app’s tools a second time. Name the compose service
and set run:
[tasks.laravel-schedule]cron = "* * * * *"compose_file = "/srv/myapp/compose.yaml"compose_service = "app"run = "php artisan schedule:run"RunWisp runs the command with docker compose exec in the running container.
The task’s env, secrets, and params are passed in, stdout and stderr stay
separate, and fail-fast applies. It works
whether you started the app with docker compose up or RunWisp runs it via
[compose.*].
The container must already be running, and timeout can’t stop the process
inside it. compose_mode
has the details and the workaround.
Without compose
Section titled “Without compose”For a container that isn’t compose-managed, call docker exec yourself:
[tasks.laravel-schedule]cron = "* * * * *"run = "docker exec myapp-app-1 php artisan schedule:run"- No TTY. A run has no terminal.
docker exec -itfails withthe input device is not a TTY.-talone works, but adds\rto every line and merges stderr into stdout. - Use a fixed name. Compose may rename
myapp-app-1. Setcontainer_name:, or usedocker compose -f … exec <service>. - Limit the time inside.
timeoutcan’t reach the process in the container, so wrap it:docker exec myapp-app-1 timeout 3600 php artisan long-job.
A fresh container each run
Section titled “A fresh container each run”If the job is already a service in your compose file, point a task at it and
leave out run:
[tasks.nightly-backup]cron = "0 3 * * *"compose_file = "./docker-compose.yml"compose_service = "backup"RunWisp runs docker compose run --rm: a new container from the compose
file’s image, env, and networks, removed afterwards. To run your own command in
a fresh container, set run and compose_mode = "run".
Which one to use
Section titled “Which one to use”| The task… | Use |
|---|---|
belongs to an app in a container (artisan, manage.py, your DB) |
compose_service + run (exec) |
is already a compose service (backup, migrate) |
compose_service alone (fresh container) |
| targets a container that isn’t compose-managed | run = "docker exec …" |
belongs to no app (prune, restic, curl a URL) |
a plain run, with the tools installed where RunWisp runs |
Prefetch images
Section titled “Prefetch images”Pull images ahead of time so deploys start faster:
[tasks.docker-prefetch]group = "Deploys"description = "Pull the latest production images"cron = "@hourly"on_overlap = "skip"
run = """docker pull ghcr.io/example/app:currentdocker pull ghcr.io/example/worker:current"""This is only worth it if the tag changes often. There is no notify here on
purpose: a failed prefetch only slows the next deploy. Registries (GHCR, Docker
Hub, ECR) rate-limit pulls, so don’t run it more often than you need. To start
the deploy itself from CI, see Remote triggers.
Prune unused images
Section titled “Prune unused images”[tasks.docker-prune]group = "Maintenance"description = "Remove dangling images and stopped containers"cron = "0 5 * * 0" # Sunday 05:00on_overlap = "skip"notify = ["slack-ops"]
run = """docker image prune --forcedocker container prune --forcedf -h /var/lib/docker"""The df at the end keeps a record of disk use in every run’s log.
Don’t schedule docker volume prune. It deletes every volume that no running
container uses, including your database volume if its container happens to be
stopped. Run it by hand, with a label filter:
docker volume create --label keep app-pgdatadocker volume prune --filter 'label!=keep'A long-running container is a service
Section titled “A long-running container is a service”Don’t restart a container from cron:
# Don't do this[tasks.run-worker-forever]cron = "* * * * *"run = "docker run --rm ghcr.io/example/worker:current"Use a service. RunWisp restarts it when it exits:
[services.worker]restart_delay = "2s"restart_backoff = "exponential"run = """exec docker run --rm --init \\ --env-file=/etc/worker/.env \\ --network=internal \\ ghcr.io/example/worker:current"""exec replaces the shell with the docker client, so the container’s exit
code reaches RunWisp directly.
If RunWisp runs in a container
Section titled “If RunWisp runs in a container”A task runs inside the RunWisp container, so every tool it calls must be in
that image. The plain image has only bash, tzdata, and ca-certificates.
A task that calls a missing tool fails with pg_dump: not found in its log.
To check an image quickly:
docker run --rm --entrypoint sh runwisp/runwisp:latest -c 'command -v pg_dump curl'To add tools, extend the image:
FROM runwisp/runwisp:latestRUN apk add --no-cache postgresql17-client curlOn a -debian tag, use apt-get install. The entrypoint, healthcheck, and
environment defaults are kept.
To run docker from a task (anything on this page), the container also needs:
-
A Docker CLI. Use a
-dockertag, such asrunwisp/runwisp:latest-docker. It includes the Compose plugin. Without it, compose-backed runs fail withdocker compose unavailable. You can also adddocker-cli docker-cli-composein your own Dockerfile. -
The host’s Docker socket:
volumes:- /var/run/docker.sock:/var/run/docker.sock
Access to the Docker socket is root access to the host: a task can start a privileged container. Your TOML is trusted input, so this is usually fine, but decide it on purpose.