Skip to content

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.yml you already have? That’s migrating from docker-compose.
  • Want to run RunWisp itself in a container? That’s Docker in Getting started.

Everything below assumes these, and they’re worth understanding once rather than copying blindly.

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.

Terminal window
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.

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.

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 = 60
notify_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.

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.

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:

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

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

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:current
docker 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:00
on_overlap = "skip"
notify_on_failure = ["slack-ops"]
run = """
# Dangling images and stopped containers — always safe.
docker image prune --force
docker 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.

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:

Terminal window
docker volume create --label keep app-pgdata
docker 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.

Two things it needs before docker run works from inside a task:

  1. A Docker CLI in the image. The plain runwisp/runwisp tags don’t ship one — pull a -docker tag instead (runwisp/runwisp:latest-docker) and it’s already there, Compose plugin included. Without it, docker run and [compose.*] units alike fail every run with docker compose unavailable.
  2. 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.