Skip to content

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.

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.

[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 a timeout ends in a SIGKILL instead of a clean stop.
  • --network: give the container only the network it needs. internal is an example name from docker network create.
  • --env-file, not -e KEY=value: values on the command line show up in ps and in the run log.

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.

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:

Terminal window
NAME=crunch-numbers
docker rm -f "$NAME" >/dev/null 2>&1 || true
docker run --rm --init --name "$NAME" ghcr.io/example/analytics:current

With on_overlap = "skip", two copies can never run at once.

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.

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 -it fails with the input device is not a TTY. -t alone works, but adds \r to every line and merges stderr into stdout.
  • Use a fixed name. Compose may rename myapp-app-1. Set container_name:, or use docker compose -f … exec <service>.
  • Limit the time inside. timeout can’t reach the process in the container, so wrap it: docker exec myapp-app-1 timeout 3600 php artisan long-job.

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

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

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:current
docker 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.

[tasks.docker-prune]
group = "Maintenance"
description = "Remove dangling images and stopped containers"
cron = "0 5 * * 0" # Sunday 05:00
on_overlap = "skip"
notify = ["slack-ops"]
run = """
docker image prune --force
docker container prune --force
df -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:

Terminal window
docker volume create --label keep app-pgdata
docker volume prune --filter 'label!=keep'

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.

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:

Terminal window
docker run --rm --entrypoint sh runwisp/runwisp:latest -c 'command -v pg_dump curl'

To add tools, extend the image:

FROM runwisp/runwisp:latest
RUN apk add --no-cache postgresql17-client curl

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

  1. A Docker CLI. Use a -docker tag, such as runwisp/runwisp:latest-docker. It includes the Compose plugin. Without it, compose-backed runs fail with docker compose unavailable. You can also add docker-cli docker-cli-compose in your own Dockerfile.

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