Manual

Stop docker compose from orphaning anonymous volumes

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 pull alpine:3.24.2 for the test stack.
Time
15 min.
Risk
rm -v deletes 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
    0
    
  • Placeholders used below:

    • <SCRIPT> — the script that runs your stack (benchmark, test run).
    • <PROJECT> — the Compose project name it uses: the -p value; without -p, Compose takes COMPOSE_PROJECT_NAME, then a top-level name: in the compose file, then the name of the directory holding the compose file.
    • <CONTAINER> — one container of that stack, as listed by docker 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. Every volume row 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 VOLUME paths, run with a named volume on /data/db only (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/db
    

    Anonymous volumes come from two places: a Compose volumes: entry with only a container path (- /anon), and every VOLUME path in the image that nothing is mounted onto. Current official images declare, for example: mongo VOLUME /data/db /data/configdb (7.0 to 9.0); postgres VOLUME /var/lib/postgresql/data (16, 17) and VOLUME /var/lib/postgresql (18); mysql and mariadb VOLUME /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 - /data entry, and an image VOLUME path), the recreated container saw an empty directory both times, and in the - /data case the old data was still in the previous anonymous volume, now attached to nothing, which is the kind of volume docker volume prune deletes. 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.

  1. 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:
    YAML
    

    Expected: you are in anonvol/, and compose.yaml is the only file there.

  2. 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 only com.docker.volume.anonymous. Once its container is gone, nothing on an anonymous volume says which project made it.

  3. Tear down with plain down and 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"
    1fca8d0c2829e9760baec3cf83f3e8a4cc2c493b3c2b3d88ddf1164532058243
    

    Expected: down removes the container and the network, and the anonymous volume is still listed, attached to nothing. The last command removes this one test orphan.

  4. Tear down with rm -fsv, then down.

    $ 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_named
    

    Expected: no such volume for the anonymous one, anonvol_named still listed. rm removes only stopped service containers, so -s stops them first; -f skips the confirmation prompt (the Going to remove line still prints); -v removes the anonymous volumes attached to them. down then removes the network. If rm prints No stopped containers instead: Failure modes, row 2.

  5. Find every teardown in your script. The leaking pattern is a plain down at the end of the run plus the same down in the EXIT trap. To follow along on the test stack, save this stand-in as bench.sh in anonvol/:

    #!/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" down
    

    Then 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
    50f304513f423d0d22988e94ec107b7d0a3debde0c4b34585fec763e06c95597
    

    Expected: grep shows 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's down came second, found nothing left and logged a warning ending in Warning: No resource found to remove for project \"anonvol\". The last command removes the two test orphans. Run the same grep on <SCRIPT>.

  6. Replace the teardown with one trap that runs rm -fsv, then down, both with -p. The fixed stand-in, saved over bench.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 EXIT
    

    Expected: no down outside cleanup, and rm -fsv before down. In <SCRIPT>, give both commands the -p <PROJECT> its up uses and no -f: the trap may run in another directory, where a relative -f path or a missing -p makes Compose look for a compose file that is not there (Failure modes, rows 3 and 4). With -p alone, rm and down worked from an empty directory. The trap line comes before up, so a failing up ends the script through the trap like the failing benchmark in Verify. Under set -e, a command that fails inside cleanup ends it too, so a failed rm also skips down.

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

    Expected: Going to remove and Removed from the trap, exit status 0, no such volume for 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.sh brought the leaking version back; cmp against 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 printed 0):
    $ 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.2
    
    Then cd .. and delete the anonvol/ directory.
  • The anonymous volumes that rm -v deleted cannot be brought back. Restoring the old script only stops deleting new ones.

Failure modes#

Symptom (exact message)CauseFix
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 trapSteps 5 and 6
docker compose -p anonvol rm -fsv prints No stopped containers and the anonymous volume is still listedrm ran after down: down had already removed the containers without their volumes, so rm had nothing to act onRun rm -fsv first, then down; delete the orphan with docker volume rm <VOLUME>
no configuration file provided: not founddocker 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 runningdocker compose -p anonvol -f compose.yaml rm -fsv with a relative -f, run from another directoryDrop -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 137The script was killed with SIGKILL, so the EXIT trap never ran and the container still runs with the volume attacheddocker 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 info reported 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. Every down or rm -fsv that 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's sleep runs 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 -f still prints Going 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 was alpine:3.24.2 committed with docker commit --change 'VOLUME ["/data/db", "/data/configdb"]' and run with a named volume on /data/db only: it got an anonymous volume on /data/configdb, plain down orphaned it, rm -fsv removed 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 -fsv with 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 SIGKILL docker volume rm refused 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 under set -e ended that function. Afterwards all test containers, networks, volumes, the stand-in image and the alpine:3.24.2 tag were removed, checked by name filters and by docker volume inspect on 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 with docker compose down (no -v) and an EXIT trap doing the same, one orphan per run. docker compose rm -fsv before down removes 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 -v removes the anonymous volumes too, but also the named volumes declared in the compose file (Undo above shows anonvol_named going). 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 prune removes the unused volumes that carry the com.docker.volume.anonymous label, from every project on the host, not only this stack's; -a widens 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 image VOLUME line (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.anonymous label on anonymous volumes created through the mount API, which is how Compose creates - /path volumes (moby issue 48748, docker/compose issue 10833). On such an engine, an orphan from a - /path entry has no labels at all, and a plain docker volume prune leaves it alone.
  • Plain docker: docker run --rm and docker rm -v remove a container's anonymous volumes and leave named ones, the same split as compose rm -v.
  • Not covered: one-off containers from docker compose run. The compose rm docs say a plain rm removes them; the v5.6.0 source excludes them. Not tested here.

Filed under: docker, containers, bash, linux

Sources#