Stop docker compose from orphaning anonymous volumes
Contents
Tear a script's compose stack down with docker compose rm -fsv, then docker compose down, in one EXIT trap, so each run's anonymous volumes are deleted with their containers and the named volumes stay.
- Tested on
- a Fedora 44 workstation (x86_64), Docker Engine 29.9.1 with the containerd image store, Docker Compose v5.6.0, GNU bash 5.3.9, GNU coreutils 9.10 for
timeout(2026-10-25), against a throwaway test stack. - Not verified
- Docker Desktop, rootless Docker, Podman, other Compose versions, one-off containers from
docker compose run, moving data out of an anonymous volume. - Needs
- Docker Engine with the Compose plugin and permission to run
docker; the script that brings your stack up and down; network access to pullalpine:3.24.2for the test stack. - Time
- 15 min.
- Risk
rm -vdeletes the anonymous volumes of the containers it removes, and there is no undo for that. Anything you want to keep must sit in a named volume or a bind mount before you change the teardown (checked in Before you start). Named volumes and bind mounts are not touched.
Before you start#
Docker, Compose and bash versions, and whether the test image tag is already on the box (
0= no; Undo removes the tag only in that case):$ docker version --format '{{.Server.Version}}' 29.9.1 $ docker compose version Docker Compose version v5.6.0 $ bash --version | head -1 GNU bash, version 5.3.9(1)-release (x86_64-redhat-linux-gnu) $ docker image ls --filter 'reference=alpine:3.24.2' --format '{{.Repository}}:{{.Tag}}' | wc -l 0Placeholders used below:
<SCRIPT>— the script that runs your stack (benchmark, test run).<PROJECT>— the Compose project name it uses: the-pvalue; without-p, Compose takesCOMPOSE_PROJECT_NAME, then a top-levelname:in the compose file, then the name of the directory holding the compose file.<CONTAINER>— one container of that stack, as listed bydocker compose -p <PROJECT> ps.<VOLUME>— the name of one anonymous volume (64 hex characters).
Back up the script:
cp <SCRIPT> <SCRIPT>.bak. Edit<SCRIPT>only while no run of it is in progress: bash reads a script as it executes it.With your stack up, run this for each container listed by
docker compose -p <PROJECT> ps. Everyvolumerow with a 64-character hex name is anonymous, and the new teardown deletes it:docker inspect -f '{{.Name}}{{range .Mounts}}{{println}} {{.Type}} {{.Name}} -> {{.Destination}}{{end}}' <CONTAINER>Output for a stand-in image that declares MongoDB's two
VOLUMEpaths, run with a named volume on/data/dbonly (how it was built: Field notes):$ docker inspect -f '{{.Name}}{{range .Mounts}}{{println}} {{.Type}} {{.Name}} -> {{.Destination}}{{end}}' anonvol2-db-1 /anonvol2-db-1 volume 5f8dc2fb359c192091a7e14bd087a2251ed275b05247a9d3289028cfe992a54a -> /data/configdb volume anonvol2_dbdata -> /data/dbAnonymous volumes come from two places: a Compose
volumes:entry with only a container path (- /anon), and everyVOLUMEpath in the image that nothing is mounted onto. Current official images declare, for example:mongoVOLUME /data/db /data/configdb(7.0 to 9.0);postgresVOLUME /var/lib/postgresql/data(16, 17) andVOLUME /var/lib/postgresql(18);mysqlandmariadbVOLUME /var/lib/mysql.If a row you need is anonymous, stop here. Pointing that path at a new named volume does not move the data: in a probe on the same box (a
- /dataentry, and an imageVOLUMEpath), the recreated container saw an empty directory both times, and in the- /datacase the old data was still in the previous anonymous volume, now attached to nothing, which is the kind of volumedocker volume prunedeletes. Copy the data into a named volume first; that procedure is not covered here.
Steps#
Steps 1 to 4 show the behaviour on your Docker with a throwaway project called anonvol; steps 5 to 7 change the script.
Create the test stack in an empty directory.
mkdir -p anonvol && cd anonvol cat > compose.yaml <<'YAML' services: app: image: alpine:3.24.2 command: sleep 600 volumes: - /anon # anonymous volume - named:/keep # named volume volumes: named: YAMLExpected: you are in
anonvol/, andcompose.yamlis the only file there.Start it and read the labels of both volumes.
$ docker compose -p anonvol up -d Image alpine:3.24.2 Pulling Image alpine:3.24.2 Pulled Network anonvol_default Creating Volume anonvol_named Creating Volume anonvol_named Creating Network anonvol_default Creating Volume anonvol_named Created Volume anonvol_named Created Network anonvol_default Created Network anonvol_default Created Container anonvol-app-1 Creating Container anonvol-app-1 Created Container anonvol-app-1 Starting Container anonvol-app-1 Started $ docker inspect -f '{{.Name}}{{range .Mounts}}{{println}} {{.Type}} {{.Name}} -> {{.Destination}}{{end}}' anonvol-app-1 /anonvol-app-1 volume 1fca8d0c2829e9760baec3cf83f3e8a4cc2c493b3c2b3d88ddf1164532058243 -> /anon volume anonvol_named -> /keep $ A=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/anon"}}{{.Name}}{{end}}{{end}}' anonvol-app-1) $ echo "$A" 1fca8d0c2829e9760baec3cf83f3e8a4cc2c493b3c2b3d88ddf1164532058243 $ docker volume inspect -f '{{json .Labels}}' "$A" {"com.docker.volume.anonymous":""} $ docker volume inspect -f '{{json .Labels}}' anonvol_named {"com.docker.compose.config-hash":"4456136bca9a68374890f14351c9dfc1be9e440fed2c7f0c2012e8157fdec197","com.docker.compose.project":"anonvol","com.docker.compose.version":"5.6.0","com.docker.compose.volume":"named"}Expected: the named volume carries
com.docker.compose.project; the anonymous one carries onlycom.docker.volume.anonymous. Once its container is gone, nothing on an anonymous volume says which project made it.Tear down with plain
downand see the orphan.$ docker compose -p anonvol down Container anonvol-app-1 Stopping Container anonvol-app-1 Stopped Container anonvol-app-1 Removing Container anonvol-app-1 Removed Network anonvol_default Removing Network anonvol_default Removed $ docker volume ls --filter dangling=true --filter name="$A" DRIVER VOLUME NAME local 1fca8d0c2829e9760baec3cf83f3e8a4cc2c493b3c2b3d88ddf1164532058243 $ docker volume rm "$A" 1fca8d0c2829e9760baec3cf83f3e8a4cc2c493b3c2b3d88ddf1164532058243Expected:
downremoves the container and the network, and the anonymous volume is still listed, attached to nothing. The last command removes this one test orphan.Tear down with
rm -fsv, thendown.$ docker compose -p anonvol up -d Network anonvol_default Creating Network anonvol_default Creating Network anonvol_default Created Network anonvol_default Created Container anonvol-app-1 Creating Container anonvol-app-1 Created Container anonvol-app-1 Starting Container anonvol-app-1 Started $ A=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/anon"}}{{.Name}}{{end}}{{end}}' anonvol-app-1) $ docker compose -p anonvol rm -fsv Container anonvol-app-1 Stopping Container anonvol-app-1 Stopped Going to remove anonvol-app-1 Container anonvol-app-1 Removing Container anonvol-app-1 Removed $ docker compose -p anonvol down Network anonvol_default Removing Network anonvol_default Removed $ docker volume inspect "$A" [] Error response from daemon: get b33ec27afc546708492a31254fa7d883af806e900858dacf4a3fd9f308186064: no such volume $ docker volume ls --filter name=anonvol_named DRIVER VOLUME NAME local anonvol_namedExpected:
no such volumefor the anonymous one,anonvol_namedstill listed.rmremoves only stopped service containers, so-sstops them first;-fskips the confirmation prompt (theGoing to removeline still prints);-vremoves the anonymous volumes attached to them.downthen removes the network. IfrmprintsNo stopped containersinstead: Failure modes, row 2.Find every teardown in your script. The leaking pattern is a plain
downat the end of the run plus the samedownin the EXIT trap. To follow along on the test stack, save this stand-in asbench.shinanonvol/:#!/usr/bin/env bash # The leaking pattern: plain `down` at the end of the run AND in the EXIT trap. set -euo pipefail P=anonvol trap 'docker compose -p "$P" down' EXIT docker compose -p "$P" up -d echo "anonymous volume this run: $(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/anon"}}{{.Name}}{{end}}{{end}}' "$P-app-1")" sh -c "${BENCH:-echo benchmark ran}" # stand-in for the real benchmark docker compose -p "$P" downThen back it up, list its teardown lines and run it twice (the runs are cut to the relevant lines):
$ chmod +x bench.sh $ cp bench.sh bench.sh.bak $ grep -nE 'trap|compose .*(down|rm)' bench.sh 2:# The leaking pattern: plain `down` at the end of the run AND in the EXIT trap. 5:trap 'docker compose -p "$P" down' EXIT 10:docker compose -p "$P" down $ ./bench.sh; echo "exit status $?" … anonymous volume this run: a1ab691293e32a68e43b2a1e3b829decb16236b09634ebb68e4ab4cbdf36bec2 benchmark ran … exit status 0 $ ./bench.sh; echo "exit status $?" … anonymous volume this run: 50f304513f423d0d22988e94ec107b7d0a3debde0c4b34585fec763e06c95597 benchmark ran … exit status 0 $ docker volume ls --filter dangling=true --filter name=a1ab691293e32a68e43b2a1e3b829decb16236b09634ebb68e4ab4cbdf36bec2 --filter name=50f304513f423d0d22988e94ec107b7d0a3debde0c4b34585fec763e06c95597 DRIVER VOLUME NAME local 50f304513f423d0d22988e94ec107b7d0a3debde0c4b34585fec763e06c95597 local a1ab691293e32a68e43b2a1e3b829decb16236b09634ebb68e4ab4cbdf36bec2 $ docker volume rm a1ab691293e32a68e43b2a1e3b829decb16236b09634ebb68e4ab4cbdf36bec2 50f304513f423d0d22988e94ec107b7d0a3debde0c4b34585fec763e06c95597 a1ab691293e32a68e43b2a1e3b829decb16236b09634ebb68e4ab4cbdf36bec2 50f304513f423d0d22988e94ec107b7d0a3debde0c4b34585fec763e06c95597Expected:
grepshows every line that tears the stack down (here line 10 and the trap on line 5); two runs leave two dangling anonymous volumes, one per run. In each run the trap'sdowncame second, found nothing left and logged a warning ending inWarning: No resource found to remove for project \"anonvol\".The last command removes the two test orphans. Run the samegrepon<SCRIPT>.Replace the teardown with one trap that runs
rm -fsv, thendown, both with-p. The fixed stand-in, saved overbench.sh:#!/usr/bin/env bash # Stand-in for a benchmark script: bring the stack up, run, always tear down. set -euo pipefail P=anonvol cleanup() { docker compose -p "$P" rm -fsv # stop + remove containers AND their anonymous volumes docker compose -p "$P" down # then the network; named volumes stay } trap cleanup EXIT docker compose -p "$P" up -d echo "anonymous volume this run: $(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/anon"}}{{.Name}}{{end}}{{end}}' "$P-app-1")" sh -c "${BENCH:-echo benchmark ran}" # stand-in for the real benchmark$ grep -nE 'trap|compose .*(down|rm)' bench.sh 7: docker compose -p "$P" rm -fsv # stop + remove containers AND their anonymous volumes 8: docker compose -p "$P" down # then the network; named volumes stay 10:trap cleanup EXITExpected: no
downoutsidecleanup, andrm -fsvbeforedown. In<SCRIPT>, give both commands the-p <PROJECT>itsupuses and no-f: the trap may run in another directory, where a relative-fpath or a missing-pmakes Compose look for a compose file that is not there (Failure modes, rows 3 and 4). With-palone,rmanddownworked from an empty directory. The trap line comes beforeup, so a failingupends the script through the trap like the failing benchmark in Verify. Underset -e, a command that fails insidecleanupends it too, so a failedrmalso skipsdown.Run the fixed script.
$ ./bench.sh; echo "exit status $?" Network anonvol_default Creating Network anonvol_default Creating Network anonvol_default Created Network anonvol_default Created Container anonvol-app-1 Creating Container anonvol-app-1 Created Container anonvol-app-1 Starting Container anonvol-app-1 Started anonymous volume this run: e13ed1d5f741e54e85914e40ba8db2d8a520350c2bd6735e16e4f0de839c7ade benchmark ran Container anonvol-app-1 Stopping Container anonvol-app-1 Stopped Going to remove anonvol-app-1 Container anonvol-app-1 Removing Container anonvol-app-1 Removed Network anonvol_default Removing Network anonvol_default Removed exit status 0 $ docker volume inspect e13ed1d5f741e54e85914e40ba8db2d8a520350c2bd6735e16e4f0de839c7ade [] Error response from daemon: get e13ed1d5f741e54e85914e40ba8db2d8a520350c2bd6735e16e4f0de839c7ade: no such volume $ docker volume ls --filter name=anonvol_named DRIVER VOLUME NAME local anonvol_namedExpected:
Going to removeandRemovedfrom the trap,exit status 0,no such volumefor this run's anonymous volume, the named volume still there.
Verify#
The trap also has to fire when the run fails or is stopped. A failing benchmark (exit 3), then a timeout that sends SIGTERM to the script after 15 s:
$ BENCH='exit 3' ./bench.sh; echo "exit status $?"
…
anonymous volume this run: 44d1c47ad43e7b601df303a2e0d4df4ae33f94eb5da0dc044162c3b587b7e35b
Container anonvol-app-1 Stopping
Container anonvol-app-1 Stopped
Going to remove anonvol-app-1
Container anonvol-app-1 Removing
Container anonvol-app-1 Removed
Network anonvol_default Removing
Network anonvol_default Removed
exit status 3
$ docker volume inspect 44d1c47ad43e7b601df303a2e0d4df4ae33f94eb5da0dc044162c3b587b7e35b
[]
Error response from daemon: get 44d1c47ad43e7b601df303a2e0d4df4ae33f94eb5da0dc044162c3b587b7e35b: no such volume
$ BENCH='sleep 60' timeout 15 ./bench.sh; echo "exit status $?"
…
anonymous volume this run: 2a877c27f64027f6d856e916a3481f1fa4d928294fd1cd02ff3a1b6125ea5e35
Container anonvol-app-1 Stopping
Container anonvol-app-1 Stopped
Going to remove anonvol-app-1
Container anonvol-app-1 Removing
Container anonvol-app-1 Removed
Network anonvol_default Removing
Network anonvol_default Removed
exit status 124
$ docker volume inspect 2a877c27f64027f6d856e916a3481f1fa4d928294fd1cd02ff3a1b6125ea5e35
[]
Error response from daemon: get 2a877c27f64027f6d856e916a3481f1fa4d928294fd1cd02ff3a1b6125ea5e35: no such volume
$ docker compose -p anonvol ps -a -q | wc -l
0
Expected: the trap ran in both cases; exit status 3 is the benchmark's own status, passed through the trap, and 124 is what timeout returns for a command it had to stop. Each run's anonymous volume is gone and no container of the project is left. SIGINT also ran the trap in a probe on the same box (bash 5.3.9). SIGKILL did not (tested with timeout -s KILL, exit status 137): whatever sends SIGKILL, such as kill -9 or the kernel's OOM killer, ends bash without cleanup. The container then keeps running with its anonymous volume attached, so docker volume rm <VOLUME> refuses with volume is in use (Failure modes, row 5). Run the trap's two commands by hand, docker compose -p <PROJECT> rm -fsv and then docker compose -p <PROJECT> down, or start the fixed script once more: it found the container still running (Container anonvol-app-1 Running), and its trap removed the container and that volume.
For <SCRIPT>: start the fixed script, and while its stack is up, run the docker inspect from Before you start and take one anonymous name as <VOLUME>. After the script ends, docker volume inspect <VOLUME> must print no such volume.
Undo / rollback#
- Script:
cp <SCRIPT>.bak <SCRIPT>. On the test stand-in,cp bench.sh.bak bench.shbrought the leaking version back;cmpagainst the original leaking script found no difference. - Test stack, after the last run (removes
anonvol_named; remove the image tag only if Before you start printed0):Then$ docker compose -p anonvol down -v Volume anonvol_named Removing Volume anonvol_named Removed $ docker volume ls --filter name=anonvol_named DRIVER VOLUME NAME $ docker image rm alpine:3.24.2 Untagged: alpine:3.24.2cd ..and delete theanonvol/directory. - The anonymous volumes that
rm -vdeleted cannot be brought back. Restoring the old script only stops deleting new ones.
Failure modes#
| Symptom (exact message) | Cause | Fix |
|---|---|---|
One more dangling anonymous volume after every run; its only label is {"com.docker.volume.anonymous":""} | Every teardown is a plain docker compose down (no -v): at the end of the script and in the EXIT trap | Steps 5 and 6 |
docker compose -p anonvol rm -fsv prints No stopped containers and the anonymous volume is still listed | rm ran after down: down had already removed the containers without their volumes, so rm had nothing to act on | Run rm -fsv first, then down; delete the orphan with docker volume rm <VOLUME> |
no configuration file provided: not found | docker compose rm -fsv without -p, run in a directory with no compose file (as a trap can be) | Pass -p <PROJECT>: docker compose -p anonvol rm -fsv worked from an empty directory |
compose file "~/…/elsewhere/compose.yaml" is invalid: open ~/…/elsewhere/compose.yaml: no such file or directory, and the container keeps running | docker compose -p anonvol -f compose.yaml rm -fsv with a relative -f, run from another directory | Drop -f from the trap's rm and down; keep -p <PROJECT> |
Error response from daemon: remove <VOLUME>: volume is in use - [<container id>] after a run ended with exit status 137 | The script was killed with SIGKILL, so the EXIT trap never ran and the container still runs with the volume attached | docker compose -p <PROJECT> rm -fsv, then docker compose -p <PROJECT> down; or run the fixed script again |
Field notes#
- 2026-10-25, Fedora 44 workstation (x86_64), Docker Engine 29.9.1 (containerd image store), Compose v5.6.0, bash 5.3.9, coreutils 9.10: Re-ran the two-volume test for this manual from driver scripts that write one output file per console block. The versions block and the blocks in Steps, Verify and Undo come from one run in manual order (01:51 to 01:52 local time); the stand-in mounts block and Failure-modes rows 1 to 3 come from separate runs between 01:42 and 01:50, rows 4 and 5 from follow-up runs on the test stack at 02:16 and about 02:22.
docker inforeported the containerd image store (driver-type io.containerd.snapshotter.v1). Compose printed plain progress lines (several of them twice) because its output went to a file; a terminal shows the animated view. Everydownorrm -fsvthat had to stop the container took 10.25 to 10.57 s (Compose's own timer in the pseudo-terminal run:Stopped … 10.3s): the test container'ssleepruns as PID 1, which ignores SIGTERM unless it handles it, so Docker waited out the 10 s default stop timeout and then killed it.rm -fstill printsGoing to remove anonvol-app-1; it only skips the question. The Bash manual does not say whether the EXIT trap runs when an untrapped SIGTERM ends a script; on bash 5.3.9 it did, at the 15 s mark: the whole run took 25.7 s, the 15 s to the signal plus the 10 s stop. The SIGTERM case was repeated with a pseudo-terminal as stdin and stdout: same result, 25.5 s. The stand-in for MongoDB wasalpine:3.24.2committed withdocker commit --change 'VOLUME ["/data/db", "/data/configdb"]'and run with a named volume on/data/dbonly: it got an anonymous volume on/data/configdb, plaindownorphaned it,rm -fsvremoved it and kept the named one. Follow-up probes on the same box: switching an anonymous path to a new named volume left the container with an empty directory and the data in the old, now dangling, anonymous volume;rm -fsvwith a relative-f, run from another directory, failed (reproduced afterwards on the test stack, Failure modes row 4); SIGINT and SIGTERM ran the EXIT trap, SIGKILL did not (exit status 137), and after a SIGKILLdocker volume rmrefused the still-attached volume while the next normal run or the trap's two commands by hand removed it (reproduced afterwards on the test stack, row 5); and a failing command inside an EXIT-trap function underset -eended that function. Afterwards all test containers, networks, volumes, the stand-in image and thealpine:3.24.2tag were removed, checked by name filters and bydocker volume inspecton every anonymous volume name the runs printed. - 2026-10-23/24 (exact day not recorded), same workstation: Found during a Docker cleanup in which each destructive step waited for my approval. Docker held 16 GB of volumes, 247 of them unattached. Anonymous volumes carry only the label
com.docker.volume.anonymous, so nothing said which project had made them; I mounted them read-only into one container and looked for marker files. 75 were throwaway MongoDB instances: system collections only, about 200 MB of preallocated journal each. Root cause: a benchmark script ended withdocker compose down(no-v) and an EXIT trap doing the same, one orphan per run.docker compose rm -fsvbeforedownremoves containers with their anonymous volumes and keeps named ones, verified then with a two-volume test compose. The Compose version used that day was not recorded.
Notes#
docker compose down -vremoves the anonymous volumes too, but also the named volumes declared in the compose file (Undo above showsanonvol_namedgoing). Use it only when all of the stack's data is disposable.- Orphans from earlier runs stay until you remove them. Since Engine 23.0 (API 1.42), a plain
docker volume pruneremoves the unused volumes that carry thecom.docker.volume.anonymouslabel, from every project on the host, not only this stack's;-awidens it to every unused volume, named ones included. It selects by that label, so it skips unlabelled orphans (next note), and 23.0.0 and 23.0.1 also skipped volumes from an imageVOLUMEline (fixed in 23.0.2). The cleanup this manual came from listed and classified the orphans before deleting them: Docker's image age is somebody else's clock. - Engines before 27.4.0 did not put the
com.docker.volume.anonymouslabel on anonymous volumes created through the mount API, which is how Compose creates- /pathvolumes (moby issue 48748, docker/compose issue 10833). On such an engine, an orphan from a- /pathentry has no labels at all, and a plaindocker volume pruneleaves it alone. - Plain
docker:docker run --rmanddocker rm -vremove a container's anonymous volumes and leave named ones, the same split ascompose rm -v. - Not covered: one-off containers from
docker compose run. Thecompose rmdocs say a plainrmremoves them; the v5.6.0 source excludes them. Not tested here.
Filed under: docker, containers, bash, linux
Sources#
- docker compose down — "Anonymous volumes are not removed by default";
-vremoves the named volumes declared in the file and anonymous volumes attached to containers. - docker compose rm — removes stopped service containers; text of
-f,-s,-v; the one-off container sentence. - docker container rm and docker container run —
-vand--rmremove anonymous volumes only; the PID 1 signal note. - docker container stop — SIGTERM, then SIGKILL; default timeout 10 s for Linux containers.
- docker volume prune and Engine 23.0 release notes — anonymous-only by default from API 1.42 (Engine 23.0),
-afor named; 23.0.2 fixedVOLUME-line volumes being excluded from prune. - moby
daemon/volume/service/convert.go—withPruneadds the filterlabel=com.docker.volume.anonymousunlessall=true. - Engine 27 release notes, moby issue 48748 and docker/compose issue 10833 — the missing label on mount-API anonymous volumes, which is how Compose creates
- /pathvolumes; fixed in 27.4.0. - moby
daemon/volume/service/service.go—AnonymousLabel = "com.docker.volume.anonymous", set on volumes created without a name. - Specify a project name —
-p,COMPOSE_PROJECT_NAME,name:, directory name, in that order. - Compose v5.6.0
pkg/compose/remove.go—rmexcludes one-off containers and passes-vto the Engine's remove-volumes flag. - Official image Dockerfiles: mongo 9.0 (line 111), postgres 17 (line 187), postgres 18 (line 192), mysql 8.4 (line 117), mariadb 11.8 (line 137); the other versions named above have the same lines.
- GNU Bash manual —
trap ... EXITruns "when the shell exits"; silent on SIGTERM. timeout --help, GNU coreutils 9.10 — sends TERM on timeout; exits 124 when the command timed out.- Source package for this manual (2026-10-23/24 cleanup, anonymized) — the 75 MongoDB orphans, the root cause, the original two-volume test.