Docker security checklist

Docker Volume Permissions Checklist: 7 Checks (2026)

Volumes are the bridge between a container and the host filesystem, and the permissions on that bridge decide how much of the host a compromised container can read or rewrite. This page documents all 7 checks in the CtrlOps Volume Permissions audit: what each one reads, the exact value that passes, the values that warn or fail, and the command to fix it.

Hiren KalariyaLast reviewed: Aug 22, 202614 min read
7
checks in this audit
2
rated high severity
13s
automated run time
7
need root or sudo

Every pass, warning and failure threshold on this page is transcribed from the docker-security-volume-permissions audit script that CtrlOps runs, and a build check fails if the two ever disagree. See how the audit runs.

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.sock is caught. Four are MEDIUM and one is LOW.
  • All seven need root. Three query the daemon, and four read /var/lib/docker/volumes directly, 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, find and stat by hand.
#CheckSeverityWhat it catchesRoot needed
1Sensitive host path mountsHIGH/, /etc, /proc, docker.sock and friends bind-mounted inYes
2Writable volume mountsHIGHA read-write docker.sock, and the read-write to read-only ratioYes
3World-writable volumesMEDIUMVolume data any local user can modifyYes
4Volume ownershipMEDIUMData owned by a UID nothing accounts forYes
5Volume directory permissionsMEDIUMA loose mode on the volumes root directoryYes
6Dangling volumesLOWVolumes no container references any moreYes
7Read-only enforcementMEDIUMContainers with a writable root filesystemYes
Checks 3, 4 and 5 assume the default data root. They read /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?

High severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSNo container bind-mounts any path on the sensitive listThe full list of paths checked is named in the finding.
FAILOne or more containers bind-mount a sensitive host pathEach hit is reported as container, source and destination, so you know exactly what was handed to what.
SKIPDocker is not installed, or the daemon cannot be queriedNothing 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 nginx

If 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:2375
A read-write docker.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?

High severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSNo writable docker.sock, whether or not volumes existTwo 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.
FAILdocker.sock is mounted read-write into a containerThe containers are named. This is equivalent to root on the host, so mount it read-only at minimum, or remove it.
SKIPDocker is not installed, or the daemon cannot be queriedNothing 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 myapp
services:
  app:
    volumes:
      - ./config:/app/config:ro
      - ./logs:/app/logs          # the only one that needs write access
Bottom line: default-writable is Docker's most consequential default. Add :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?

Medium severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSNo world-writable volume data directories were foundVolume data is protected from unprivileged host accounts.
FAILOne or more volume data directories are world-writableThe count and up to three examples are named. Run chmod o-w unless every container using that volume genuinely needs it.
SKIPThe audit account has no root, or there is no /var/lib/docker/volumes directoryTwo 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}}'
Bottom line: world-writable volume data is almost always accidental, created by a container running as root with a permissive umask. The fix is one 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?

Medium severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSEvery volume is owned by a host user or by a uid some container is configured to useOwnership is traceable to something that exists.
WARNVolume data is owned by a uid matching neitherUp to five volumes are named with their uid. This is most likely data left by a removed image, so confirm before deleting.
SKIPThe audit account has no root, or there is no /var/lib/docker/volumes directoryCannot 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>
Orphaned volumes accumulate quietly. A server running Docker for a year can hold a dozen of them containing database dumps, application logs or credentials from images that were removed months ago. They are not listed by anything you look at day to day, which is exactly why they are worth a quarterly pass.

Check 5: Is the volume directory permission set correctly?

Medium severityNeeds sudo

/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 thresholds this check applies
ResultWhenWhat it means
PASSThe mode is 700, 701, 710 or 711The actual mode is named. Local accounts cannot traverse into volume data.
WARNThe mode is anything elseThe actual mode is named. Docker normally sets 701 or 711, and anything looser lets local accounts traverse into every volume.
SKIPThe audit account has no root, there is no /var/lib/docker/volumes, or stat returned nothingThree 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/volumes
Bottom line: this is a one-command fix that protects every volume on the host at once. If the mode is 755 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?

Low severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSNo dangling volumesEvery volume is attached to a container.
WARNOne or more dangling volumes existThe count and up to five names are listed. They may hold stale data from removed containers, so review before pruning.
SKIPDocker is not installed, or the daemon cannot be queriedNothing 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 prune

Then stop them accumulating:

docker compose down --volumes
docker rm -v <container-name>
Prune is not a safe default on a server you inherited. A stopped container you plan to restart, and any volume holding data between rebuilds, can both look dangling from the daemon's point of view. Inspect each one and back up anything that might be production data before removing it.

Check 7: Are containers running with a read-only root filesystem?

Medium severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSEvery container runs with a read-only root filesystemThe container count is named. Writes are confined to the paths you mounted.
WARNOne or more containers have a writable root filesystemThe 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.
SKIPDocker is not installed, the daemon cannot be queried, or there are no containers at allNothing 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 \
  myapp
services:
  app:
    read_only: true
    tmpfs:
      - /tmp:rw,noexec,nosuid
      - /var/run:rw,noexec,nosuid
    volumes:
      - app-data:/app/data      # the only writable path

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

Bottom line: 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.

TaskBy hand, 8 hostsCtrlOps, 8 hosts
Run all 7 checksAbout 50 minutesAbout 105 seconds
Sensitive mount detectionManual path matching per containerAutomatic, including stopped containers
Orphaned UID detectionCross-reference by handCompares host users and container configs automatically
Get a scored reportNot availableAutomatic
Re-audit after fixingAnother 50 minutesAnother 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.

Audit your fleet

Run all these checks on every server, in one click

CtrlOps runs this audit over your existing SSH connection - no agents, no scripts to manage. $7/user/month after a 1 month free trial - no credit card required.

Windows

✓ Start instantly·✓ No credit card·✓ No sneaky autorenewals