New to Kestra?
Use blueprints to kickstart your first workflows.
Remove terminal Docker containers past an age threshold, scoped by label and a protect-list, with their anonymous volumes, never forced, then verify.
Exited, created and dead containers pile up on CI runners and build hosts. Every one of them still pins its writable layer and its anonymous volumes, so the disk fills with the remains of builds that finished weeks ago. docker system prune frees it, but it is all-or-nothing and age-blind: it takes the container someone kept to read its logs, the one a job exited two minutes ago, the data volume nobody meant to lose. So teams either never run it or run it and lose something.
This blueprint reaps by age inside a boundary you set. It reads only containers carrying a label you apply yourself, keeps anything matching a protect-list, keeps anything that stopped recently, caps how many go in one run, then removes the rest with their anonymous volumes and without --force. Then it lists the host again and fails the run if a target survived.
This blueprint was created by zkasuran.
Deleting containers is not reversible, so the blueprint is built so that it cannot reach anything but its own target:
label_selector, so a container without that label is never even returned, let alone removed. The label is one you apply to the containers your builds create.protected_name_pattern wins over every other rule. A container matching it is kept even when it carries the label and is old.force: false. A running container is refused by the daemon rather than killed. This is what makes the gap between detection and removal safe: a container that started again in between stays up, so the verification step fails the run instead of reporting success.min_age_minutes is a target.max_removals bounds one run, so a selector that matches more than you expected cannot empty a host in one execution.auto_remediate true, dry_run false, then an approved Pause. The shipped defaults and the disabled schedule are read-only.list_terminal (io.kestra.plugin.core.http.Request, read-only) asks the Docker Engine API for containers matching label_selector and sitting in one of target_states. candidates keeps their ids and names.
inspect_each (io.kestra.plugin.core.flow.Loop, read-only) inspects each candidate. The stop time, the exit code and the anonymous volume names are in the inspect payload only, not in the list.
window freezes the age cutoff once, so detection and verification agree. assess gives every candidate a verdict:
KEEP_RUNNING: the daemon reports it running;KEEP_PROTECTED: its name matches protected_name_pattern;KEEP_FRESH: it stopped after the cutoff (a never-started container is dated by its creation time);KEEP_OVER_CAP: eligible, but beyond max_removals for this run;REAP: terminal, old, unprotected, inside the cap.summary logs the counts and every target.
route stops with a log line when there is nothing to reap. Otherwise findings_alert posts the plan to Slack and remediate_gate checks auto_remediate and dry_run.
hold (io.kestra.plugin.core.flow.Pause) waits for approval. remediate_reap (Loop, concurrencyLimit: 1) then runs one io.kestra.plugin.docker.cli.Rm per target with removeVolumes: true and force: false.
relist reads every labelled container again, in any state. result compares the plan with the host. verify fails the run if a target is still present or if a container the plan kept has gone.
errors alerts Slack when the flow fails, including when the verification gate fails.
docker system prune and stopped running it since.A cron job running docker container prune --filter until=24h cannot keep a protect-list, cannot stop at a ceiling, cannot ask for approval and cannot tell you what it removed. It also cannot check: if the removal is refused, the exit code is lost in a log nobody reads. Kestra reads the host, computes the plan, holds the change behind an approval, removes one container at a time so a failure is attributable, then reads the host again and fails the execution when the result does not match the plan. The execution history is the record of what was removed from which host and who approved it.
On Kestra 2.0.5 OSS against an isolated Docker 27.5.1 daemon (Docker-in-Docker on its own network), with a local HTTP sink standing in for Slack. The daemon held six containers: reap-me (labelled, exited, with an anonymous volume), created-only (labelled, never started), prod-ledger (labelled, exited, matching the protect-list), too-fresh (labelled, exited seconds ago), no-label (exited, no label, with an anonymous volume), keep-running (labelled, running).
| Run | Inputs | Result |
|---|---|---|
| 1 | defaults, min_age_minutes: 1440 |
3 candidates read, 0 targets, everything inside the age window. Nothing removed |
| 2 | min_age_minutes: 2 |
4 candidates, 2 targets (reap-me, created-only). too-fresh kept as fresh, prod-ledger kept by the protect-list, no-label never returned by the query, keep-running never returned by the state filter |
| 3 | run 2 plus auto_remediate: true |
Same plan, nothing removed. The dry run blocks the change even with auto_remediate on |
| 4 | run 3 plus dry_run: false, pause approved |
Both targets removed with the one anonymous volume they owned. Verified with the daemon's own CLI: 6 containers before, 4 after, 2 anonymous volumes before, 1 after |
| 5 | same, pause resumed with approved: false |
Nothing removed |
| 6 | same, pause resumed with no answer at all | Nothing removed, an unanswered pause does not read as approval |
| 7 | same, but the target was started again during the pause | The daemon refused the removal with HTTP 409 because force is false, verify failed the run and errors posted to Slack |
| 8 | min_age_minutes: 0, max_removals: 1 |
2 eligible, 1 target, the other reported as KEEP_OVER_CAP |
| 9 | unreachable daemon | errors alert sent |
--label kestra.reap=true to the docker run in your CI job or set it in the runner configuration, then point label_selector at it.docker run -d --privileged --name dind -e DOCKER_TLS_CERTDIR= docker:27-dind --host=tcp://0.0.0.0:2375, create a few labelled containers inside it, then set docker_host to tcp://dind:2375.SLACK_WEBHOOK_URL: used by findings_alert and the errors block. In Kestra OSS, provide it as the base64-encoded environment variable SECRET_SLACK_WEBHOOK_URL.| Input | Default | Purpose |
|---|---|---|
docker_host |
tcp://docker-host:2375 |
Daemon to clean |
label_selector |
kestra.reap=true |
The only containers read |
target_states |
exited, created, dead |
States treated as terminal |
protected_name_pattern |
^(prod|db|state|kestra)([-_.].*)?$ |
Names never removed |
min_age_minutes |
1440 |
How long a container must have been stopped |
max_removals |
25 |
Ceiling for one run |
auto_remediate |
false |
Must be true to remove anything |
dry_run |
true |
Must be false to remove anything |
SLACK_WEBHOOK_URL secret.docker run --label kestra.reap=true ....docker_host and label_selector, run it with the defaults and read the plan.auto_remediate: true and dry_run: false, read the plan in the paused execution, then resume it with approved: true.nightly trigger once the plans look right. Change its inputs when you want it to act.io.kestra.plugin.docker.cli.Prune with VOLUMES to also clear volumes whose container is already gone.