Bash Script to Reclaim Docker Disk Space

– min read
The disk-full alert fired at 3am, and of course it was the box running Docker. `du -sh /var/lib/docker` said 324K, `df` said the disk was nearly gone, and none of the usual suspects added up. What I actually needed was a repeatable way to see what Docker was holding and claw it back, so I wrote this bash script. Built against Docker 29 on Ubuntu 26.04. Docs I leaned on: docker system df and the pruning reference.

What it cleans

Stopped containers, images nothing references anymore, networks nobody uses, and the build cache. Each one is a one-liner in plain docker, but the point is seeing the whole picture before deleting anything, plus a before/after number you can paste into a ticket. The script reports by default and only deletes when you pass –prune.


Requirements

  1. A Docker host where you can run docker commands (nothing needs to be root if your user is in the docker group).
  2. numfmt from coreutils for the byte formatting (present on every Ubuntu I have touched).
  3. Read access to /var/lib/docker and /var/lib/containerd if you want the footprint line in the report.


The script

I built this in a throwaway lab VM with Docker 29.1.3, seeded it with junk (three exited containers, an orphaned network, an image nothing used) and ran every mode against that. The outputs below are the real captured stdout, and the whole dataset is synthetic: no real workload was touched.


#!/usr/bin/env bash
# docker-reclaim.sh - report and clean up Docker disk hogs
# (c) 2026 mzulfahmy - MIT
#
# Run without arguments for a report only. Nothing gets deleted.
#   -p, --prune   prune exited containers, dangling images, unused networks
#   -a, --all     also remove unused *tagged* images (image prune -a)
#   -c, --cache   also clear the build cache
#   -h, --help    this help
#
# Exit codes: 0 ok, 1 error, 2 nothing to do (prune mode).

set -euo pipefail

PRUNE=0 ALL=0 CACHE=0

usage() { sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'; }

for arg in "$@"; do
    case "$arg" in
        -p|--prune) PRUNE=1 ;;
        -a|--all)   ALL=1 ;;
        -c|--cache) CACHE=1 ;;
        -h|--help)  usage; exit 0 ;;
        *) echo "unknown option: $arg" >&2; usage >&2; exit 1 ;;
    esac
done

command -v docker >/dev/null || { echo "docker not found in PATH" >&2; exit 1; }
docker info >/dev/null 2>&1 || { echo "cannot talk to the docker daemon" >&2; exit 1; }

# Total footprint of the docker data root in bytes. Docker 25+ ships the
# containerd snapshotter, which stores blobs outside DockerRootDir (usually
# /var/lib/containerd), so sum both trees.
ROOT="$(docker info -f '{{.DockerRootDir}}')"
CTRD="${ROOT%/docker}/containerd"
footprint() {
    local total=0 n
    for d in "$ROOT" "$CTRD"; do
        [ -d "$d" ] || continue
        n="$(du -sx --block-size=1 "$d" 2>/dev/null | awk '{print $1}')"
        total=$((total + n))
    done
    echo "$total"
}

human() { numfmt --to=iec --suffix=B "$1" 2>/dev/null || echo "${1}B"; }

report() {
    echo "== Docker disk report: $ROOT (+ $CTRD) =="
    printf "dockerd data root : %s\n" "$(human "$(footprint)")"
    docker system df
    echo "-- exited containers:"
    E="$(docker ps -aq --filter status=exited | wc -l)"
    echo "   $( [ "$E" -eq 0 ] && echo 'none' || echo "$E container(s): $(docker ps -a --filter status=exited --format '{{.Names}}' | paste -sd, -)" )"
    echo "-- unused networks:"
    NU="$(docker network ls -q --filter dangling=true | wc -l)"
    echo "   $( [ "$NU" -eq 0 ] && echo 'none' || docker network ls --filter dangling=true --format '   {{.Name}} ({{.Driver}})' )"
    echo "-- images without a tag (dangling):"
    DI="$(docker images -f dangling=true -q | wc -l)"
    echo "   $( [ "$DI" -eq 0 ] && echo 'none' || echo "$DI image(s)" )"
}

BEFORE="$(footprint)"
if [ "$PRUNE" -eq 0 ]; then
    report
    echo
    echo "(report only - re-run with --prune to actually remove things)"
    exit 0
fi

report
echo
echo "== pruning =="

# 1. stopped containers (their writable layers are the first to pile up)
if C="$(docker container prune -f)"; then echo "$C"; fi

# 2. dangling images only
if I="$(docker image prune -f)"; then echo "$I"; fi

# 3. networks nothing points at
if N="$(docker network prune -f)"; then echo "$N"; fi

# 4. optional: every image no container uses, not just dangling ones
if [ "$ALL" -eq 1 ]; then
    if IA="$(docker image prune -a -f)"; then echo "$IA"; fi
fi

# 5. optional: build cache
if [ "$CACHE" -eq 1 ]; then
    if BC="$(docker builder prune -f)"; then echo "$BC"; fi
fi

AFTER="$(footprint)"
RECLAIMED=$((BEFORE - AFTER))
echo
echo "Docker data root: $(human "$BEFORE") -> $(human "$AFTER")"
echo "Reclaimed: $(human "$RECLAIMED")"
[ "$RECLAIMED" -gt 0 ] || exit 2


Step 1: look before you touch

Run it with no arguments. The default mode only reads the daemon and prints what is reclaimable: exited containers by name, dangling networks, untagged images, and the footprint of the whole data root.


== Docker disk report: /var/lib/docker (+ /var/lib/containerd) ==
dockerd data root : 34MB
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          3         2         34.48MB   15.03MB (43%)
Containers      3         0         12.29kB   12.29kB (100%)
Local Volumes   0         0         0B        0B
Build Cache     0         0         0B        0B
-- exited containers:
   3 container(s): exited-job,web-stale,web-old
-- unused networks:
   orphan-net-1 (bridge)
-- images without a tag (dangling):
   none

(report only - re-run with --prune to actually remove things)


docker-reclaim.sh report mode showing reclaimable docker space
Report mode output (synthetic lab data)


Step 2: reclaim it

–prune removes exited containers, untagged images and unused networks. Add –all to also drop tagged images no container uses, and –cache for the build cache. The script prints the data-root footprint before and after, so the reclaimed number is real, not the sum of docker’s own optimistic estimates.


== Docker disk report: /var/lib/docker (+ /var/lib/containerd) ==
dockerd data root : 34MB
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          3         2         34.48MB   15.03MB (43%)
Containers      3         0         12.29kB   12.29kB (100%)
Local Volumes   0         0         0B        0B
Build Cache     0         0         0B        0B
-- exited containers:
   3 container(s): exited-job,web-stale,web-old
-- unused networks:
   orphan-net-1 (bridge)
-- images without a tag (dangling):
   none

== pruning ==
Deleted Containers:
285f5474900612eda3012d89a7ef73ffc15b719b49a6e0bdb3296bd5008c6865
48b8512ce4df6d8e37d72e6696f766c337cec2095906fd6766201134ea60e4ed
0394555ec509a98226f4c725068d4db0218fdf3f67a7b2be3b11756f64d728a2

Total reclaimed space: 12.29kB
Total reclaimed space: 0B
Deleted Networks:
orphan-net-1
Deleted Images:
untagged: lab/webapp:v1
untagged: alpine:3.19
deleted: sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1
deleted: sha256:b58899f069c47216f6002a6850143dc6fae0d35eb8b0df9300bbe6327b9c2171
deleted: sha256:ea72def7a47260a6b8cea592a2d0e07b36131d7e0694cfa3417615f87b3ae30d
untagged: busybox:1.36
untagged: lab/keep:v1
deleted: sha256:6509450a2e78ca0eaafe42c847bfe612142c8e7390513c2e1570f89e9259f6ff
untagged: lab/orphan:latest
deleted: sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662
deleted: sha256:b7f3d86d6e84fc17718c48bcde1450807faa2d56704205c697b4bd5df7b9e29f
deleted: sha256:89ac95482c5fefb46a0d3470431760940c36e9c0ff0c40e9e5c259091a26b1e3

Total reclaimed space: 13.74MB

Docker data root: 34MB -> 680KB
Reclaimed: 34MB


docker-reclaim.sh pruning containers, networks and unused images
–prune –all run on the lab host (synthetic data)


Two things that bit me

The 324K vs 34MB mystery above: Docker 25+ moved to the containerd snapshotter, and with it the blobs live in /var/lib/containerd, not under DockerRootDir. Measuring only /var/lib/docker said “nothing to clean” while the disk was vanishing. The script adds both trees in footprint() and the report now agrees with df.


Second: on this storage driver there are no dangling images at all, the containerd snapshotter untags eagerly. That is why –all exists: the interesting reclaimable space was in the tagged-but-unused image (15 MB in the lab), and image prune without -a would have recovered nothing.


Cron it

For a weekly sweep, drop this in root’s crontab. The exit code is 2 when there was nothing to do, so grep the log for “Reclaimed: 0B” instead of relying on the exit status alone:


0 3 * * 0  /opt/scripts/docker-reclaim.sh --prune --all --cache >> /var/log/docker-reclaim.log 2>&1


Three weeks in, this has caught two containers from abandoned experiments and one image that was 1.4 GB of stale layers. It pairs well with the df, du and ncdu walkthrough for the flip side of the same problem, my Uptime Kuma stack is exactly the kind of long-running container that quietly leaves exited siblings behind, and the Open WebUI instance taught me to never trust a pulled model’s size to stay put.


The script is short enough to read top to bottom before you trust it, which is the whole idea: docker prune already exists, but a wrapped report-first version is what gets actually cronned instead of run once and forgotten.
Mohamad ZulfahmyCybersecurity · OffSec · Entrepreneur
About me ↗
ON THIS PAGE
On this page