Docker prune: reclaim disk space on a VPS
Your VPS disk is full and Docker is holding it. Find which of images, containers, build cache or volumes has the space, then prune it without losing data.
Find what is using the disk space before you prune anything
Docker takes up disk space on a VPS in four places: images, stopped containers, build cache, and local volumes. Run docker system df first to find out which one holds the space, then run the narrowest prune that clears it. Order matters, because the last command in this guide, docker volume prune -a, deletes data and there is no undo.
Start with the filesystem, not with Docker.
df -h /
sudo du -xh --max-depth=1 /var/lib/docker | sort -hdf tells you how bad it is. du tells you where it went. The -x flag keeps du on one filesystem, so it does not follow a mount into a separate volume and count it twice. Five directories matter here: overlay2 holds image and container layers, volumes holds volume data, containers holds container metadata and log files, buildkit holds the build cache, and image holds layer metadata.
A note on sudo and shell wildcards, because it wastes people a lot of time. /var/lib/docker is owned by root and is not readable by your normal user, so ls /var/lib/docker returns Permission denied. A command like sudo du -sh /var/lib/docker/* fails as well, because your shell expands the * before sudo ever runs, and your shell cannot read that directory. Every command below uses find or --max-depth instead of a wildcard for exactly that reason.
Now Docker's own view.
docker system dfTYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 24 6 12.4GB 8.91GB (71%)
Containers 31 6 1.42GB 1.39GB (97%)
Local Volumes 14 5 6.03GB 4.11GB (68%)
Build Cache 212 0 9.87GB 9.87GBThose figures come from one machine and say nothing about yours. Read the shape instead. TOTAL counts objects, ACTIVE counts the ones in use right now, and RECLAIMABLE is Docker's estimate of what a prune could free from that row.
Two things about RECLAIMABLE catch people out. It counts shared image layers once for every image that uses them, so the image row usually promises more than you get. And it never includes container log files, because Docker does not treat a log file as a reclaimable object. When du reports a directory far larger than docker system df admits to, log files are the reason, and there is a section on that below.
Add -v for the per-object breakdown.
docker system df -vThat splits the summary into one section per object type. The image section adds SHARED SIZE and UNIQUE SIZE columns, so you can see what a single image really costs you. The volume section adds a LINKS count, which is the number of containers attached to that volume. Remember LINKS, because a value of 0 is the entire test that the volume prune commands apply.
Dangling images versus unused images
These two words look interchangeable and are not. The filters behave differently because the objects are different.
A dangling image is an image with no tag. It shows as <none> in docker images. You create one on every rebuild: docker build -t myapp:latest . moves the myapp:latest tag onto the new image, and the old image keeps all of its layers but loses its name. Nothing references it, and nothing cleans it up on its own.
An unused image is any image, tagged or not, that no container currently refers to. A postgres:16 you pulled last month and are not running right now is unused, and it is not dangling.
docker image prune # dangling images only
docker image prune -a # every image no container refers toThe second one asks first.
WARNING! This will remove all images without at least one container associated to them.
Are you sure you want to continue? [y/N]Read that prompt closely. "Associated to them" means an existing container object, running or stopped. If you ran docker compose down, the containers are gone, so every image those services used is now unused, and -a deletes all of them. Nothing is lost that you cannot get back, but the next docker compose up -d re-pulls or rebuilds the lot, which costs bandwidth and build time on a small VPS. That is one practical reason to know what docker compose down removes and what stop leaves running before you prune anything.
A filter keeps recent images out of scope.
docker image prune -a --filter "until=240h"That removes unused images created more than 240 hours (10 days) ago and leaves newer ones alone. The until value takes a Go duration string like 240h, or an absolute timestamp such as 2026-08-01T00:00:00.
What the build cache is and why it grows without limit
BuildKit is the builder Docker has used by default for docker build and docker compose build since Docker Engine 23.0. It caches the result of every step of every Dockerfile it runs, and it keeps that cache in /var/lib/docker/buildkit. The cache is why your second build finishes in seconds, so it is doing its job. The problem is that nothing expires the old entries by default. Build the same image fifty times with a COPY step that changes each time, and you keep fifty sets of layers.
The build cache is invisible to docker image prune. It is a separate object type with its own command.
docker builder prune # dangling cache
docker builder prune -a # all unused cache
docker builder prune --filter until=168h # cache untouched for 7 daysNone of these touch your images or your data. The only cost of clearing the build cache is that the next build runs slowly once. On a VPS that rebuilds images regularly, Build Cache is often the largest row in docker system df, which makes it the safest large thing you can delete.
The prune commands, ordered from safe to destructive
Work down this list and stop as soon as df -h / looks healthy again. Every command prints a Total reclaimed space: line when it finishes.
docker container pruneremoves stopped containers. Their writable layers go too, so anything a container wrote outside a volume is deleted with it. Volumes are not touched.docker image pruneremoves dangling images only. This is the safest image command there is.docker builder pruneremoves dangling build cache. The cost is one slow build.docker image prune -aremoves every image that no container refers to. The cost is a re-pull or a rebuild.docker system prunedoes the first three at once and adds unused networks.docker volume pruneremoves unused anonymous volumes.docker volume prune -aremoves unused volumes including named ones. This is the command that deletes databases.
docker system prune states its own scope before it runs.
WARNING! This will remove:
- all stopped containers
- all networks not used by at least one container
- all dangling images
- unused build cache
Are you sure you want to continue? [y/N]Volumes are missing from that list deliberately. Adding --volumes puts anonymous volumes back in scope. Adding -a widens the image step from dangling images to every unused image. A full docker system prune -a --volumes -f on a production host is how people lose data while trying to free space.
Why pruning volumes deletes your database
This is the section to read twice.
A volume counts as unused when no container is attached to it. That is the whole test. Docker does not check whether the volume is empty, whether a compose file still declares it, or whether it holds the only copy of your database. LINKS 0 in docker system df -v means prunable, and it means nothing else.
Now put two ordinary actions in a row. You run docker compose down to restart a stack cleanly. That removes the containers and leaves the named volumes in place, which is exactly what it is documented to do. Your Postgres volume is now attached to nothing. Ten minutes later you run docker volume prune -a to free space, and the database is gone. Both commands behaved correctly. The sequence destroyed the data.
Since Docker Engine 23.0 (API version 1.42) the plain command is narrower than it used to be.
WARNING! This will remove anonymous local volumes not used by at least one container.An anonymous volume is one Docker created for you, usually because an image declares VOLUME and you never gave it a name. Those normally hold data you did not ask to keep. A named volume, the kind you wrote into your compose file, is only removed when you add -a. Older Docker versions removed both with the plain command, so do not trust habits formed on a box you have since upgraded. The difference only makes sense once you know how named volumes differ from bind mounts, because a bind mount is not a Docker volume at all and no prune command will ever touch one.
Look before you delete. Swap myapp_pgdata for the volume name you are checking.
docker volume ls -f dangling=true
docker volume inspect myapp_pgdata
sudo ls -la /var/lib/docker/volumes/myapp_pgdata/_dataThe dangling=true filter on a volume means unreferenced, not empty. Listing _data shows you what is actually inside. If you find a pgdata or mysql directory in there, stop and take a copy before you go any further. The same destruction happens through docker compose down -v, which removes every volume the compose file declares and asks you nothing first.
A volume is the one thing on a Docker host that a rebuild cannot recreate. That is why volume data belongs in a restic backup that runs off the server, where a mistyped flag cannot reach it.
When nothing prunes: the container log files
You have pruned everything, docker system df shows almost nothing reclaimable, and the disk is still full. Check the logs.
sudo find /var/lib/docker/containers -name '*-json.log' -exec du -h {} + | sort -h | tail -10Every container writes its standard output and standard error into a JSON file under /var/lib/docker/containers/. In a default install max-size is unset, which means unlimited, so a single container stuck in a crash loop writes until the partition is full. No prune command removes these files, because the containers producing them are running, which makes them not prunable by definition.
Do not delete the file. Running rm on an open log file frees nothing, because the Docker daemon still holds an open file descriptor and the kernel keeps those blocks allocated until that handle closes. df will not move at all. Truncate instead, which keeps the same inode and lets the daemon carry on writing.
sudo find /var/lib/docker/containers -name '*-json.log' -exec truncate -s 0 {} +
df -h /That is a temporary fix. docker logs for those containers now returns nothing, and the files start growing again immediately. The real fix is rotation, which is in the next section.
Measure before and after, every time
Never guess at what a prune did. Take a reading, run one command, take another reading.
df -h /
docker system df
docker builder prune -f --filter until=168h
docker image prune -f
docker system df
df -h /Compare the two df outputs. That is the only number that decides whether your server keeps serving. docker system df then tells you which row actually moved, and each prune prints its own Total reclaimed space: figure.
If df did not move but docker system df says space was freed, an open file handle is holding deleted blocks, which is the log file problem above. If both moved and the disk fills up again within a day, you have a growth problem rather than a cleanup problem, and the answer is rotation plus a scheduled job.
How to stop the disk filling up again
Cap the log size. Create or edit /etc/docker/daemon.json.
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}That holds each container to 30 MB of logs. Every value under log-opts must be a string, including the numeric ones. Check the file parses before you restart, because a malformed daemon.json stops the daemon from starting at all and takes every container down with it.
python3 -m json.tool /etc/docker/daemon.json
sudo systemctl restart docker
docker info | grep -i 'logging driver'docker info should now report Logging Driver: json-file. The limits themselves show up in the LogConfig section of docker inspect on a container created after the restart, which is the important detail: this setting applies to new containers only. Existing containers keep the configuration they were built with, so recreate them.
docker compose up -d --force-recreateThe same limit can go per service in a compose file, which is the better choice when one noisy service needs its own number.
services:
app:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"Schedule a narrow prune. Weekly, scoped to dangling images and old build cache. Never put -a or --volumes in a scheduled job, because a job that fires while a stack is down will delete that stack's images, and with --volumes it starts on your data.
sudo tee /etc/cron.weekly/docker-prune >/dev/null <<'EOF'
#!/bin/sh
docker image prune -f
docker builder prune -f --filter until=168h
EOF
sudo chmod +x /etc/cron.weekly/docker-prune
sudo /etc/cron.weekly/docker-pruneThat last line runs the script once by hand so you see its output before it ever runs unattended. The file has to be executable, and its name must not contain a dot, because run-parts skips anything non-executable and anything carrying an extension.
Alarm on free space. A prune you run after the disk fills is a recovery. An alert at 80 percent is prevention.
usage=$(df --output=pcent / | tr -dc '0-9')
[ "$usage" -ge 80 ] && echo "root filesystem at ${usage} percent full"Put that in cron with whatever notifier you already use. Free space is only half of the picture, so pair the alarm with disk health monitoring on your VPS, because a failing disk and a full disk both stop your containers and the two need different fixes.
Everything above assumes a standard install with the data root at /var/lib/docker. If you moved it with the data-root key in daemon.json, substitute your path in every command. Getting that layout right on a fresh box is part of setting up Docker on a VPS, and it is far easier to decide before you have 40 GB of containers sitting on the wrong partition.
FAQ
Does docker system prune delete my volumes?
No. The plain command removes stopped containers, unused networks, dangling images and unused build cache, and its confirmation prompt lists exactly that set. Volumes only come into scope when you add --volumes, and since Docker Engine 23.0 that flag covers anonymous volumes rather than named ones. Named volumes are removed by docker volume prune -a and by docker compose down -v. Those are the two commands to be careful with.
Why is my disk still full after running docker prune?
There are two usual causes. The first is container log files under /var/lib/docker/containers/, which no prune command touches and which grow without limit until you set max-size. The second is a deleted file that a process still holds open: if you removed a log with rm while its container was running, the daemon keeps the file descriptor and the kernel does not release the blocks, so df reports no change. Compare sudo du -xh --max-depth=1 /var/lib/docker against docker system df to see which one you have.
What is the difference between docker image prune and docker image prune -a?
The plain command removes dangling images only, meaning images that lost their tag, almost always to a rebuild. The -a form removes every image that no existing container refers to, including tagged images you pulled deliberately. After a docker compose down the containers are gone, so -a will take that stack's images with it. Nothing is permanently lost, because the next start re-pulls or rebuilds them, but on a slow link that is a long wait.
How do I stop Docker logs from filling the disk?
Set max-size and max-file under log-opts in /etc/docker/daemon.json, then restart the daemon with sudo systemctl restart docker. The setting only applies to containers created after that restart, so recreate the running ones with docker compose up -d --force-recreate. You can set the same two options per service in a compose file under a logging key, which is what you want when one service is far noisier than the rest.
Is it safe to run docker system prune in a cron job?
The plain docker system prune -f is safe on a host where every stack stays up, but it removes stopped containers, so it will delete a container you stopped on purpose and meant to restart later. The safer scheduled job is docker image prune -f plus docker builder prune -f --filter until=168h, which frees the two rows that grow fastest and cannot touch a volume. Never schedule -a or --volumes.