Skip to content

Docker tasks

A task can run a command in one of your running containers, or start a new container for each run. Your stack keeps running from docker compose up; RunWisp only adds the scheduled jobs. To let RunWisp supervise the services of a compose file instead, see From docker-compose.

runwisp.toml
[tasks.laravel-schedule]
cron = "* * * * *"
compose_file = "/srv/shop/compose.yaml"
compose_service = "app"
run = "php artisan schedule:run"

RunWisp runs the command in the running app container with docker compose exec, so it gets the app’s code, environment, and credentials. Use it for anything that belongs to an app: an artisan command, a manage.py job, psql against your database.

  • The task’s env, secrets, and params are passed in, stdout and stderr stay separate, and fail-fast applies.
  • The container must already be running. A run that finds none fails, and its log names the compose project RunWisp searched.
  • timeout can’t stop the process inside the container. Limit it in the command too: run = "timeout 3600 php artisan long-job".

compose_mode has the details.

For a container that isn’t compose-managed, call docker exec yourself:

runwisp.toml
[tasks.laravel-schedule]
cron = "* * * * *"
run = "docker exec myapp php artisan schedule:run"
  • No -it. A run has no terminal, and 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. Set --name (or container_name:) on the container, so the name in the task doesn’t change.
  • Limit the time inside, as above: docker exec myapp timeout 3600 php artisan long-job.

A job with its own image (a backup, a migration, a report) can be a compose service that RunWisp starts on a schedule:

compose.yaml
services:
# ...
backup:
image: postgres:17
profiles: [jobs]
environment:
PGPASSWORD: ${DB_PASSWORD}
command: pg_dump -h db -U shop -f /dumps/shop.sql shop
volumes:
- ./dumps:/dumps
runwisp.toml
[tasks.backup-db]
cron = "0 3 * * *"
compose_file = "/srv/shop/compose.yaml"
compose_service = "backup"

Each run is docker compose run --rm backup: a new container with the service’s image, environment, and volumes, removed when it exits. It runs in the same compose project as the rest of the stack, so it reaches db by name and mounts the stack’s named volumes.

profiles: [jobs] keeps docker compose up from starting the job with the rest of the stack. RunWisp still runs it.

To run your own command in a fresh container instead of the service’s, set run and compose_mode = "run". The new container publishes no ports and takes no network alias, so it can run beside the service.

The task… Use
belongs to an app in a container (artisan, manage.py, your DB) compose_service + run (exec)
has its own image (backup, migrate) compose_service alone (fresh container)
targets a container that isn’t compose-managed run = "docker exec …"
runs an image that is in no compose file run = "docker run --rm …"
belongs to no app (prune, restic, curl a URL) a plain run, with the tools installed where RunWisp runs

A task runs inside the RunWisp container. To run docker or compose_file tasks from there, the runwisp service needs a Docker CLI and the host’s Docker socket:

compose.yaml
services:
runwisp:
image: runwisp/runwisp:latest-docker
# ...
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# ...
  1. A Docker CLI. A -docker tag includes the CLI and the Compose plugin. Without it, compose tasks fail with docker compose unavailable. You can also add docker-cli docker-cli-compose in your own Dockerfile.
  2. The host’s Docker socket. It 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.

A task that runs a command in a service of RunWisp’s own compose project can leave out compose_file. RunWisp reads the project from its own container:

runwisp.toml
[tasks.laravel-schedule]
cron = "* * * * *"
compose_service = "app"
run = "php artisan schedule:run"

A task that starts a fresh container, or targets another compose project, needs compose_file. Mount the project directory at its host path:

compose.yaml
volumes:
- /srv/shop:/srv/shop:ro

The CLI in the RunWisp container reads compose.yaml, and the host’s Docker creates the containers. Compose names the project after the directory and resolves ./dumps and env_file: against it. At the same path, both match what docker compose up used on the host. RunWisp only reads the directory, so :ro is enough.

With the directory mounted at another path, such as /srv, an exec run fails with:

service "app" is not running
No running "app" container in compose project "srv" (from /srv/compose.yaml). Start the stack, or if it runs under another project name (see `docker compose ls`), set that name as the top-level `name:` in the compose file.

docker compose ls on the host shows the project your stack runs under. Mount the directory at its host path, or set that project name as name: at the top of compose.yaml.

See Running in Docker for the rest of the container’s setup.

For an image that is in no compose file:

runwisp.toml
[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)
"""
  • --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.
  • No -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. Attached, the container’s output goes into the run log and its exit code becomes the run’s.
  • --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.
  • --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.
  • --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:

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

Don’t restart a container from cron:

runwisp.toml
# 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:

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

Pull images ahead of time so deploys start faster:

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

runwisp.toml
[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'