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.
Run a command in an existing container
Section titled “Run a command in an existing container”[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, andparamsare 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.
timeoutcan’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.
A container outside compose
Section titled “A container outside compose”For a container that isn’t compose-managed, call docker exec yourself:
[tasks.laravel-schedule]cron = "* * * * *"run = "docker exec myapp php artisan schedule:run"- No
-it. A run has no terminal, anddocker 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. Set
--name(orcontainer_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.
Run a compose service on a schedule
Section titled “Run a compose service on a schedule”A job with its own image (a backup, a migration, a report) can be a compose service that RunWisp starts on a schedule:
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[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.
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) |
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 |
If RunWisp runs in a container
Section titled “If RunWisp runs in a container”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:
services: runwisp: image: runwisp/runwisp:latest-docker # ... volumes: - /var/run/docker.sock:/var/run/docker.sock # ...- A Docker CLI. A
-dockertag includes the CLI and the Compose plugin. Without it, compose tasks fail withdocker compose unavailable. You can also adddocker-cli docker-cli-composein your own Dockerfile. - 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:
[tasks.laravel-schedule]cron = "* * * * *"compose_service = "app"run = "php artisan schedule:run"Fresh containers and other projects
Section titled “Fresh containers and other projects”A task that starts a fresh container, or targets another compose project, needs
compose_file. Mount the project directory at its host path:
volumes: - /srv/shop:/srv/shop:roThe 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 runningNo 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.
Start a container with docker run
Section titled “Start a container with docker run”For an image that is in no compose file:
[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 detacheddocker runprints a container ID and exits0at 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 atimeoutends 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.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.
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.
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'