Docker patterns
Sooner or later a task of yours is going to be a container. The nightly
aggregation job ships as an image, the report generator needs a Python
runtime you don’t want on the host, the migration tool is easier to
docker run than to install. This page is about that: docker as
the thing inside run =.
Two neighbours, so you land on the right page:
- Want RunWisp to supervise the services in a
docker-compose.ymlyou already have? That’s migrating from docker-compose. - Want to run RunWisp itself in a container? That’s Docker in Getting started.
Two flags before anything else
Section titled “Two flags before anything else”Everything below assumes these, and they’re worth understanding once rather than copying blindly.
--rm, always
Section titled “--rm, always”Without it, every single run leaves a dead container behind. A task
that fires hourly leaves you 8,700 Exited containers a year, quietly
eating disk until something more important fails because of it.
docker run --rm … # ✅docker run … # ❌ one corpse per run, forever--rm is handled by the Docker daemon, not the client, so the cleanup
still happens even if RunWisp kills the client on timeout.
Never -d
Section titled “Never -d”A detached docker run prints a container ID and exits 0
immediately. RunWisp sees a run that finished in 40 milliseconds and
succeeded — while your actual job is only just starting, and its
failure, its output, and its duration all happen somewhere RunWisp
can’t see. You’ve reintroduced exactly the silence you installed
RunWisp to get rid of.
Stay attached. Then the container’s stdout and stderr stream into the run log, its exit code becomes the run’s exit code, and its wall time is the run’s duration.
A one-shot container as a task
Section titled “A one-shot container as a task”The bread-and-butter case: RunWisp fires, a container does one job and exits.
[tasks.crunch-numbers]group = "Analytics"description = "Nightly aggregation of yesterday's events"cron = "0 4 * * *"on_overlap = "skip"timeout = "2h"keep_runs = 60notify_on_failure = ["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)"""The RunWisp side of that — cron,
on_overlap,
timeout,
keep_runs,
notify_on_failure — has its own pages.
Here’s why the Docker side looks like that.
--memory / --cpus. RunWisp is frugal with RAM, but that won’t
save you: a runaway job that eats the host’s memory gets the OOM killer
involved, and the OOM killer doesn’t know which process you care
about. Capping the container means the container dies instead of your
daemon — and you get a failed run with an alert rather than a mystery.
--init. Runs a tiny init as PID 1 inside the container. It
forwards signals to your actual process and reaps zombies. Without it,
many images ignore SIGTERM entirely, which means your timeout turns
into a hard SIGKILL five seconds later instead of a clean shutdown.
--network=internal. Give the container what it needs to reach and
nothing more. If the job only talks to your database, it has no
business having general internet access. (internal is just a name —
use whatever your docker network create set up.)
--env-file, not -e. Anything on the command line is visible in
ps and lands in the run log. A file you can chmod 0600 doesn’t.
Secrets: let RunWisp hold them
Section titled “Secrets: let RunWisp hold them”If the values live in your runwisp.toml world rather than in a file
Docker already knows about, use RunWisp’s
secrets / secrets_file
and pass them through by name:
[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 with no =value tells Docker “take this one from my
environment” — so the value goes daemon → task → container without ever
appearing in your config, your run log, or ps. Values under secrets
never leave the daemon; the Web UI shows the file path, not the
contents.
About that timeout
Section titled “About that timeout”When timeout fires, RunWisp signals the task’s whole process group,
waits graceful_stop (5s by default),
then SIGKILLs. The attached docker run client forwards the first
signal into the container, so a well-behaved image shuts down properly.
An image that ignores SIGTERM is a different story: the client gets killed, and the container keeps running, orphaned from the run that started it. If that’s a real risk for you, name the container and clear the old one up front:
NAME=crunch-numbersdocker rm -f "$NAME" >/dev/null 2>&1 || truedocker run --rm --init --name "$NAME" ghcr.io/example/analytics:currentNow the next run reclaims the name no matter how the previous one
died. Combined with on_overlap = "skip", you can’t get two of them.
Prefetching the next image
Section titled “Prefetching the next image”A “warm the cache” task so deploys don’t spend their first minute pulling:
[tasks.docker-prefetch]group = "Deploys"description = "Pull the latest production images so deploys are quick"cron = "@hourly"on_overlap = "skip"# No failure alerts on purpose — see below.
run = """docker pull ghcr.io/example/app:currentdocker pull ghcr.io/example/worker:current"""Only bother if your :current tag actually moves often — hourly suits
teams deploying several times a day; for everyone else, let the deploy
itself do the pull. Pair it with the
deploy-hooks recipe.
No notify_on_failure here, deliberately. A missed prefetch costs the
next deploy a few seconds and nothing else, and an alert nobody needs
to act on is how people learn to ignore alerts. You’ll still see the
red run in the Web UI if you go looking. And keep an eye on registry
rate limits — GHCR, Docker Hub and ECR all count pulls, and a hot
prefetch loop across a fleet burns through the budget for no benefit.
Pruning, without shooting yourself in the foot
Section titled “Pruning, without shooting yourself in the foot”Disks fill up when nobody’s watching. Schedule a prune of the things that are always safe to drop:
[tasks.docker-prune]group = "Maintenance"description = "Reclaim disk: dangling images and stopped containers"cron = "0 5 * * 0" # Sunday 05:00on_overlap = "skip"notify_on_failure = ["slack-ops"]
run = """# Dangling images and stopped containers — always safe.docker image prune --forcedocker container prune --force
# Leave a record of what we ended up with.df -h /var/lib/docker"""That df at the end isn’t decoration. Every run keeps its output, so
six months from now the run history tells you whether disk use has been
creeping up or holding steady.
Leave volumes out of it
Section titled “Leave volumes out of it”docker volume prune deletes every volume no running container
references — including the one holding your database, if its container
happens to be stopped when the prune fires. Sunday at 05:00 is exactly
when that’s most likely.
Run it by hand instead, with a label filter protecting anything you care about:
docker volume create --label keep app-pgdatadocker volume prune --filter 'label!=keep' # BY HAND, after checking…and check what’s actually labelled in your environment first. On a schedule, this one eventually finds you.
Long-running container? That’s a service
Section titled “Long-running container? That’s a service”Here’s the antipattern to watch for:
# DON'T[tasks.run-worker-forever]run = "docker run --rm ghcr.io/example/worker:current"cron = "* * * * *" # ...restart it every minute if it died?“Run forever, restart when it exits” is what
[services.*] does, and a long-running
container is just a long-running command:
[services.worker]description = "Queue worker; supervised by RunWisp"restart_delay = "2s"restart_backoff = "exponential"run = """exec docker run --rm --init \\ --env-file=/etc/worker/.env \\ --network=internal \\ ghcr.io/example/worker:current"""The exec replaces the shell with the docker client instead of
leaving it hanging around as a parent. Signals reach the container
either way — RunWisp signals the whole process group — but the process
tree stays clean and the container’s exit code arrives at RunWisp
unfiltered. Tasks vs services covers
the distinction properly.
If RunWisp itself runs in a container
Section titled “If RunWisp itself runs in a container”Two things it needs before docker run works from inside a task:
- A Docker CLI in the image. The plain
runwisp/runwisptags don’t ship one — pull a-dockertag instead (runwisp/runwisp:latest-docker) and it’s already there, Compose plugin included. Without it,docker runand[compose.*]units alike fail every run withdocker compose unavailable. - The Docker socket, mounted in:
-v /var/run/docker.sock:/var/run/docker.sock. Without it, both fail instead with a connection error against/var/run/docker.sock.
See Docker for what else is (and isn’t) in the image.
docker run isn’t your only option — a task can also
docker exec into a container you already have running, which skips
installing the app’s toolchain twice. That’s
running the task in another container.
Worth knowing what mounting that socket means: a task can then do anything to the host’s Docker, including starting a privileged container. The socket is effectively root on the host. That’s usually fine — your TOML is trusted input, written by you — but it’s a deliberate decision, not a detail.