Docker volume security is not really about what you store. It is about who can reach it and what they can do once they get there. A container with /etc bind-mounted can rewrite passwd and sudoers. A container with a read-write docker.sock can start a privileged one. This page documents the 7 checks in the CtrlOps Volume Permissions audit, in the order the audit runs them.
Every threshold below is transcribed from the audit script itself. One detail is worth carrying through the whole page: unlike the other Docker audits, four of these checks inspect every container on the host, stopped ones included, because a stopped container's mounts still describe what the host handed out and its volume data is still sitting on disk.
Key takeaways
Seven checks, split between what containers were given and what is left behind on the filesystem.
- Two are rated HIGH: sensitive host path mounts and writable mounts, which is where a writable
docker.sockis caught. Four are MEDIUM and one is LOW. - All seven need root. Three query the daemon, and four read
/var/lib/docker/volumesdirectly, which is mode 701 on a healthy host and unreadable without it. - Checks 1, 2, 6 and 7 cover stopped containers too. A container that is not running still has its mounts recorded, and restarting it re-grants every one of them.
- Checks 3, 4 and 5 read the filesystem, not Docker. They work on a host where the daemon is down, and they skip when the data root has been moved somewhere other than
/var/lib/docker. - The whole audit takes about 13 seconds when CtrlOps runs it over your existing SSH connection, against roughly 6 minutes of
docker inspect,findandstatby hand.
| # | Check | Severity | What it catches | Root needed |
|---|---|---|---|---|
| 1 | Sensitive host path mounts | HIGH | /, /etc, /proc, docker.sock and friends bind-mounted in | Yes |
| 2 | Writable volume mounts | HIGH | A read-write docker.sock, and the read-write to read-only ratio | Yes |
| 3 | World-writable volumes | MEDIUM | Volume data any local user can modify | Yes |
| 4 | Volume ownership | MEDIUM | Data owned by a UID nothing accounts for | Yes |
| 5 | Volume directory permissions | MEDIUM | A loose mode on the volumes root directory | Yes |
| 6 | Dangling volumes | LOW | Volumes no container references any more | Yes |
| 7 | Read-only enforcement | MEDIUM | Containers with a writable root filesystem | Yes |
/var/lib/docker/volumes directly. If your daemon runs with a custom data-root, they report SKIP with a message that says exactly that, rather than passing. A skip here means the audit found nothing to judge, which is deliberately not the same statement as "your volumes are fine".Check 1: Are containers bind-mounting sensitive host paths?
A container with /etc mounted can rewrite passwd, shadow and sudoers. A container with / mounted owns the host outright. A container with docker.sock mounted can create a privileged container, which is the same thing with an extra step. These mounts do not weaken isolation, they remove it.
The audit inspects the bind mounts of every container, running or stopped, and matches the source path against /, /etc, /root, /boot, /proc, /sys, /dev, /var/run/docker.sock, /run/docker.sock, /var/lib/docker and /home. Both socket spellings are matched, because /var/run is a symlink to /run and docker inspect reports whichever path the container was started with.
How to check manually
docker ps -a --format '{{.Names}}' | while read c; do
mounts=$(docker inspect -f '{{range .Mounts}}{{if eq .Type "bind"}}{{.Source}} {{end}}{{end}}' "$c")
[ -n "$mounts" ] && echo "$c: $mounts"
done| Result | When | What it means |
|---|---|---|
| PASS | No container bind-mounts any path on the sensitive list | The full list of paths checked is named in the finding. |
| FAIL | One or more containers bind-mount a sensitive host path | Each hit is reported as container, source and destination, so you know exactly what was handed to what. |
| SKIP | Docker is not installed, or the daemon cannot be queried | Nothing to evaluate. Run as root, with sudo, or as a docker group member. |
There is no warning branch. A monitoring agent with /proc mounted read-only may well be a deliberate and reasonable decision, but the audit's job is to put the mount in front of you rather than to guess which ones you meant.
How to fix it
# Instead of handing over a host directory
docker run -v /etc:/host-etc nginx
# Mount only what the container actually needs, read-only
docker run -v app-config:/etc/app-config:ro nginxIf docker.sock access is genuinely required, by a CI runner or a monitoring agent, put a filtering proxy in front of the API rather than mounting the socket directly:
services:
monitor:
environment:
DOCKER_HOST: tcp://docker-proxy:2375docker.sock mount is a host takeover in one command. docker run --privileged -v /:/host alpine chroot /host from inside that container is root on the machine. If your CI runner or monitoring tool has this mount, it is one application vulnerability away from the whole host.Check 2: Are volume mounts writable when they should not be?
Docker mounts volumes read-write by default. Every volume added without :ro gives the container write access, including config files it only ever reads. The worst case is a writable docker.sock, which hands the container control of the daemon.
The audit walks every mount of every container, running or stopped, counts read-write against read-only, and flags a docker.sock mounted read-write as a failure on its own.
How to check manually
docker ps -a --format '{{.Names}}' | while read c; do
docker inspect -f '{{range .Mounts}}{{.Source}} RW={{.RW}}
{{end}}' "$c" | sed "s/^/$c: /"
done| Result | When | What it means |
|---|---|---|
| PASS | No writable docker.sock, whether or not volumes exist | Two distinct passes. With no mounts at all the finding says so. Otherwise it reports the read-write and read-only counts and suggests mounting read-only wherever a container does not need to write. |
| FAIL | docker.sock is mounted read-write into a container | The containers are named. This is equivalent to root on the host, so mount it read-only at minimum, or remove it. |
| SKIP | Docker is not installed, or the daemon cannot be queried | Nothing to evaluate. |
The pass with counts is deliberately informational. There is no correct ratio of read-write to read-only mounts, so the audit reports the numbers and leaves the judgement to you rather than inventing a threshold.
How to fix it
# Read-only bind mount
docker run -v /app/config:/config:ro myapp
# The --mount syntax says the same thing more explicitly
docker run --mount type=bind,src=/app/config,dst=/config,readonly myappservices:
app:
volumes:
- ./config:/app/config:ro
- ./logs:/app/logs # the only one that needs write access:ro to everything, then remove it only from the mounts that genuinely need writes. Start locked and open selectively, because the opposite order never gets finished.Check 3: Are volume data directories world-writable?
Named volume data lives under /var/lib/docker/volumes/<name>/_data. If a volume's data directory carries the world-writable bit, any local account on the host can modify its contents, not just root and not just the container that owns it. On a shared host, a CI server or any machine with multiple user accounts, that is a real path to tampering.
The audit scans exactly two levels deep under /var/lib/docker/volumes, which is the <name>/_data layer, for directories with permission bit 0002 set, and reports up to ten.
How to check manually
sudo find /var/lib/docker/volumes -mindepth 2 -maxdepth 2 -type d -perm -0002| Result | When | What it means |
|---|---|---|
| PASS | No world-writable volume data directories were found | Volume data is protected from unprivileged host accounts. |
| FAIL | One or more volume data directories are world-writable | The count and up to three examples are named. Run chmod o-w unless every container using that volume genuinely needs it. |
| SKIP | The audit account has no root, or there is no /var/lib/docker/volumes directory | Two distinct skips. The second one also covers a daemon configured with a custom data-root. |
How to fix it
# One volume
sudo chmod o-w /var/lib/docker/volumes/myvolume/_data
# Everything the scan flagged
sudo find /var/lib/docker/volumes -mindepth 2 -maxdepth 2 -type d -perm -0002 -exec chmod o-w {} \;Check which containers use a volume before changing its permissions:
docker ps -a --filter volume=myvolume --format '{{.Names}}'chmod, but verify the container still works afterwards, because an application running as a different uid may have been relying on that bit.Check 4: Are volumes owned by orphaned UIDs?
When an image or container is removed, its volume data stays behind. The files may be owned by a UID that maps to nothing: no host user, and no container configured to run as it. Those volumes are blind spots, because nobody knows what is in them, who wrote them or whether they hold anything sensitive.
The nuance that makes this check useful is what it does not flag. A uid with no host user is completely normal, because postgres at 999 and nginx at 101 exist only inside their images. The audit builds the list of uids that running and stopped containers are configured to use, and only reports data owned by a uid that appears in neither that list nor /etc/passwd.
How to check manually
# uids any container is configured to run as
docker ps -aq | while read c; do docker inspect -f '{{.Config.User}}' "$c"; done | cut -d: -f1 | grep -E '^[0-9]+$' | sort -u
# owners of the volume data directories
sudo sh -c 'for d in /var/lib/docker/volumes/*/_data; do printf "%s %s\n" "$(stat -c %u "$d")" "$d"; done'| Result | When | What it means |
|---|---|---|
| PASS | Every volume is owned by a host user or by a uid some container is configured to use | Ownership is traceable to something that exists. |
| WARN | Volume data is owned by a uid matching neither | Up to five volumes are named with their uid. This is most likely data left by a removed image, so confirm before deleting. |
| SKIP | The audit account has no root, or there is no /var/lib/docker/volumes directory | Cannot evaluate without root, or the data root is elsewhere. |
It warns rather than fails because orphaned data is a housekeeping problem that occasionally turns out to be the only surviving copy of something. The correct response is to look before deleting.
How to fix it
# See what is in it
sudo ls -la /var/lib/docker/volumes/<volume-name>/_data/
# Keep a copy if it matters
sudo cp -a /var/lib/docker/volumes/<volume-name>/_data/ /backup/<volume-name>/
# Then remove the volume
docker volume rm <volume-name>Check 5: Is the volume directory permission set correctly?
/var/lib/docker/volumes is the gate in front of every named volume on the host. Docker sets it to 701 or 711, which lets root manage volumes while unprivileged accounts cannot list or traverse the directory. Anything looser, 755 or 777, lets any local account walk into every volume's data at once. One permissive parent exposes everything underneath it.
The audit reads the mode of that one directory and compares it against the known-safe set: 700, 701, 710 and 711. Per-volume modes are deliberately left to check 3, so a single 777 volume produces one finding rather than two.
How to check manually
sudo stat -c '%a %n' /var/lib/docker/volumes| Result | When | What it means |
|---|---|---|
| PASS | The mode is 700, 701, 710 or 711 | The actual mode is named. Local accounts cannot traverse into volume data. |
| WARN | The mode is anything else | The actual mode is named. Docker normally sets 701 or 711, and anything looser lets local accounts traverse into every volume. |
| SKIP | The audit account has no root, there is no /var/lib/docker/volumes, or stat returned nothing | Three distinct skips, the last one covering an unreadable or unusual filesystem. |
How to fix it
sudo chmod 701 /var/lib/docker/volumes
sudo stat -c '%a %n' /var/lib/docker/volumes755 or wider today, any local account can ls and cat inside any Docker volume, database data directories included.Check 6: Are there anonymous or dangling volumes?
Dangling volumes are volumes no container references. They accumulate when containers are removed without -v, or when a Compose stack is torn down without --volumes. Each one may hold stale state: a database directory, uploaded files, a dump somebody took before a migration, or credentials that are still valid.
The audit lists volumes with Docker's own dangling=true filter and reports the count with up to five examples.
How to check manually
docker volume ls -f dangling=true
docker volume ls -f dangling=true -q | wc -l| Result | When | What it means |
|---|---|---|
| PASS | No dangling volumes | Every volume is attached to a container. |
| WARN | One or more dangling volumes exist | The count and up to five names are listed. They may hold stale data from removed containers, so review before pruning. |
| SKIP | Docker is not installed, or the daemon cannot be queried | Nothing to check. |
How to fix it
# Look before you prune
docker volume ls -f dangling=true
docker volume inspect <volume-name>
docker volume rm <volume-name>
# Only once you know what is in them
docker volume pruneThen stop them accumulating:
docker compose down --volumes
docker rm -v <container-name>Check 7: Are containers running with a read-only root filesystem?
A read-only root filesystem stops a compromised process from writing into the container: no dropped binaries, no modified application code, no persistence outside the paths you mounted deliberately. It does not prevent the compromise, it removes most of what usually comes next.
The audit reads HostConfig.ReadonlyRootfs across every container on the host, stopped ones included, and reports how many lack it out of the total.
How to check manually
docker ps -a --format '{{.Names}}' | while read c; do
echo "$c: ReadonlyRootfs=$(docker inspect -f '{{.HostConfig.ReadonlyRootfs}}' "$c")"
done| Result | When | What it means |
|---|---|---|
| PASS | Every container runs with a read-only root filesystem | The container count is named. Writes are confined to the paths you mounted. |
| WARN | One or more containers have a writable root filesystem | The count out of the total and up to five container names are listed. Enable --read-only and mount only the paths that need write access. |
| SKIP | Docker is not installed, the daemon cannot be queried, or there are no containers at all | Nothing to evaluate. |
This check overlaps with check 5 of the Container Hardening audit, and the two differ in scope and severity on purpose. That one is LOW and reads running containers as a hardening control. This one is MEDIUM and reads every container, because in a volume context a writable root filesystem is also where data ends up that nobody meant to persist.
How to fix it
docker run --read-only \
--tmpfs /tmp:rw,noexec,nosuid \
--tmpfs /var/run:rw,noexec,nosuid \
myappservices:
app:
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid
- /var/run:rw,noexec,nosuid
volumes:
- app-data:/app/data # the only writable pathTest in staging first. Most applications need /tmp, /var/run and one data directory, and the ones that need more tell you within seconds of starting.
noexec,nosuid on the tmpfs mounts is what stops the writable scratch space becoming the workaround. Without those flags an attacker can write a binary to /tmp and run it, which is most of what the read-only root was meant to prevent.What this audit does not cover
This audit reads mount paths, file permissions, ownership and read-only enforcement. It does not check:
- Container-level hardening. Privileged mode, capabilities and the runtime user are the Container Hardening audit.
- Daemon-level controls. Socket exposure, rootless mode and log rotation are the Daemon and Socket audit.
- Image contents. CVE scanning, embedded secrets and EOL base images are the Image Vulnerabilities audit.
- Network exposure. Port publishing, inter-container connectivity and env secrets are the Network and Supply Chain audit.
- Runtime limits. Seccomp, AppArmor and memory, CPU and PID caps are the Runtime and Resources audit.
- What is inside the volumes. The audit reads ownership and permissions, never file contents.
The manual audit problem at scale
These seven checks need root and move between docker inspect, find, stat and the daemon's volume list. On one host that is about six minutes. On ten servers it is an hour of repetitive commands, and the finding easiest to miss is a writable docker.sock on a container that has been stopped for a month and will hand over the host the moment somebody starts it again.
| Task | By hand, 8 hosts | CtrlOps, 8 hosts |
|---|---|---|
| Run all 7 checks | About 50 minutes | About 105 seconds |
| Sensitive mount detection | Manual path matching per container | Automatic, including stopped containers |
| Orphaned UID detection | Cross-reference by hand | Compares host users and container configs automatically |
| Get a scored report | Not available | Automatic |
| Re-audit after fixing | Another 50 minutes | Another 105 seconds |
How CtrlOps runs all 7 checks in one click
Instead of running these commands on every server by hand, CtrlOps runs the whole Volume Permissions audit over the SSH connection you already have open. Nothing is installed on the server: no agent, no daemon, no package, no new credentials. The checks are read-only POSIX shell that executes, streams its results back, and leaves nothing behind.
Watch it run
The recording below walks through the SSH and Access audit rather than this one, but the flow is identical for every audit in the catalog: pick it, watch the checks stream in, read the score, send the failures to the AI Terminal.
Step 1: Open the Audit tab
Connect to your server in CtrlOps and open the Audit tab in the left sidebar.
Step 2: Select Volume Permissions
Choose it from the Docker category. All 7 checks are listed with their descriptions, severity levels and the estimated run time, about 13 seconds in total. Every check on this audit needs root, and the UI says so before you run anything.
Step 3: Run it
The audit runs over your existing SSH session. Results stream in as they complete, so you watch each check pass, warn or fail rather than waiting on a final report.
Step 4: Read the report
You get a summary containing:
- A hardening score out of 100
- A severity breakdown: how many HIGH, MEDIUM and LOW findings
- Pass, warning, failed and skipped counts
- A findings table naming the container, the source path and the destination for every sensitive mount
Step 5: Fix what failed
Select the failed findings and send them to the AI Terminal. Each proposed fix command appears with an explanation of what it changes. Nothing runs until you review and approve it, which matters here more than anywhere else in the Docker category: a chmod on the wrong volume directory can stop an application from writing its own data.
Step 6: Re-run and compare
Run the same audit again after applying the fixes and watch the score move. Check 6 is the one to revisit quarterly, because dangling volumes accumulate from ordinary work rather than from mistakes.
Conclusion
Volume findings age differently from the rest of the Docker category. A privileged container is visible in docker ps. A volume left behind by an image you deleted last spring is visible to nothing you look at, and it may still hold a database dump with credentials that still work.
Two of these seven are HIGH, and both describe a container that was handed part of the host. The rest are about what accumulates on disk when nobody is watching. The CtrlOps Security Audit runs the set in about 13 seconds per server, agentlessly, and reads stopped containers along with running ones.
The neighbouring checklists cover the other container layers: container hardening, daemon and socket, image vulnerabilities, runtime and resources and network and supply chain.
Frequently asked questions
Because it gives the container full access to the Docker daemon API, and the daemon runs as root. From inside, that container can create a privileged container, bind-mount the host root filesystem and run commands as root on the host. A read-only mount does not prevent any of that: ro applies to the mount point, not to the protocol spoken over the socket.
Run sudo stat -c '%a %n' /var/lib/docker/volumes for the root directory, where 701 or 711 is expected. Run sudo find /var/lib/docker/volumes -mindepth 2 -maxdepth 2 -type d -perm -0002 for world-writable volume data. Use docker inspect per container for mount modes.
A bind mount maps a specific host path into the container, so you choose the location and inherit its permissions. A named volume is managed by Docker under /var/lib/docker/volumes, with Docker handling creation and lifecycle. Named volumes are safer because they do not expose arbitrary host paths, but both still need permission auditing, which is what checks 3, 4 and 5 are for.
Yes, and it treats them as a HIGH severity failure on their own rather than folding them into the mount counts. It checks every container, running or stopped, and matches both /var/run/docker.sock and /run/docker.sock, because the two are the same file and docker inspect reports whichever was used at creation.
Because a stopped container's mounts still describe what the host handed out, and docker start re-grants every one of them without asking anything. Its volume data is also still on disk. Checks 1, 2, 6 and 7 all read docker ps -a for that reason, which is why their counts can be higher than what docker ps suggests.
Add --read-only to docker run, or read_only: true in Compose, then add tmpfs mounts with noexec,nosuid for the paths the application writes to, usually /tmp and /var/run. Give real data directories a named volume. Test in staging, because an application that needs an unexpected write path fails immediately and loudly.
When you have stopped containers you intend to restart, since their volumes can appear dangling, and when volumes carry data between rebuilds, which covers most databases and upload directories. Run docker volume inspect on each one first and back up anything that might be production data. Pruning is not reversible.
They are uids owning volume data that match no user in /etc/passwd and no container's configured user. A uid with no host user on its own is normal, because image users like postgres at 999 exist only inside the image. It becomes worth reporting when no container on the host is configured to run as that uid either, which usually means the image that wrote the data has been removed.
Either the audit account cannot get root, or /var/lib/docker/volumes does not exist, which is the case on a daemon configured with a custom data-root. Those three checks read the filesystem directly rather than asking Docker, which is what lets them work when the daemon is down, and also what ties them to the default path.
After every deployment that changes mounts, and at least monthly on production. Checks 1 and 2 are the ones tied to change, because a new mount arrives with a new service. Checks 4 and 6 are the ones tied to time, because orphaned and dangling volumes accumulate from ordinary work rather than from mistakes.