The Docker daemon runs as root. Anything that can talk to it, through the Unix socket, a TCP port or group membership, has unrestricted root access to the host. That is not a misconfiguration you can fix from inside a container, it is the architecture. This page documents the 8 checks in the CtrlOps Daemon and Socket audit, in the order the audit runs them.
This is the audit to read first in the Docker category, because it covers the controls that sit above every container: who can reach the daemon, what the daemon does with container root, and whether the daemon's own output can take the host down. Every threshold below is transcribed from the audit script itself, not from general advice.
Key takeaways
Eight checks, and the first three are the whole story: an engine inventory, then the two ways daemon access leaks.
- Three are rated HIGH: TCP exposure, the socket mounted into containers, and, indirectly, everything downstream of them. Two are MEDIUM and three are LOW.
- Three need root. The inventory, the socket-mount check and the rootless check all query the daemon. The other five read the process table,
/etc/docker/daemon.jsonand/etc/group, which any account can do. - The whole audit takes about 12 seconds when CtrlOps runs it over your existing SSH connection, against roughly 5 minutes of jumping between three configuration sources by hand.
- Daemon configuration lives in three places that can contradict each other: systemd unit arguments,
/etc/docker/daemon.jsonand command-line flags. Checks 2, 5 and 7 read all of them, because missing one source means missing the finding.
| # | Check | Severity | What it catches | Root needed |
|---|---|---|---|---|
| 1 | Docker inventory | LOW | Engine version, runc version, container and image counts | Yes |
| 2 | Docker TCP socket | HIGH | A daemon reachable over the network on 2375 or 2376 | No |
| 3 | Socket in containers | HIGH | docker.sock bind-mounted into a container | Yes |
| 4 | Docker group members | MEDIUM | Accounts that are root-equivalent through the socket | No |
| 5 | Daemon log level | LOW | Debug logging that records request bodies | No |
| 6 | Rootless mode | LOW | A root daemon where rootless would work | Yes |
| 7 | User namespace remapping | MEDIUM | Container root mapping to host root | No |
| 8 | Container log limits | LOW | Unbounded json-file logs that can fill the disk | No |
Check 1: What Docker engine and runtime versions are installed?
The inventory check is informational. It records the Docker engine version, the runc version, running and total container counts, and the local image count. It matters because every check below depends on engine behaviour, and because outdated runc versions are the source of the container escape CVEs that make the rest of this page urgent.
The audit reads the versions from the daemon and from runc --version directly, because the packaged Docker version and the runc binary actually in use can be different things.
How to check manually
docker version --format '{{.Server.Version}}'
runc --version
echo "Running: $(docker ps -q | wc -l)"
echo "Total: $(docker ps -aq | wc -l)"
echo "Images: $(docker images -q | sort -u | wc -l)"| Result | When | What it means |
|---|---|---|
| PASS | The daemon is reachable and reports its versions | Informational. The engine version, runc version, running and total container counts and image count are recorded in the finding. |
| SKIP | Docker is not installed, Podman is installed instead, or the daemon is installed but unreachable | Three distinct skip messages. The Podman one names Podman, the unreachable one tells you the daemon is down or your account needs root, sudo or docker group access. |
This check never fails on its own. It exists so the rest of the report can be read against a known engine, and so you have a runc version to compare against the CVE you are worried about.
How to fix it
Nothing to fix. If the daemon is unreachable, confirm it is running and that you have permission to talk to it:
systemctl status docker
docker info >/dev/null && echo reachableCheck 2: Is the Docker daemon exposed over TCP?
The daemon listens on a Unix socket by default. Exposing it over TCP, particularly on port 2375 without TLS, gives anyone who can reach that port full root access to the host with no authentication at all. A single docker -H tcp://your-ip:2375 run --privileged from anywhere on the network owns the machine.
The audit looks in three places: listening sockets via ss, the dockerd process arguments, and the hosts array in /etc/docker/daemon.json. Any one of them naming a TCP endpoint is enough. Port 2376 implies TLS, so it warns rather than fails, but only because the port number says TLS is intended, not because the audit can prove tlsverify is on.
How to check manually
ss -tln | grep -E ':(2375|2376)$'
ps -eo args | grep '[d]ockerd' | grep -oE '\-H[= ]*tcp://[^ ]*'
grep -o 'tcp://[^"]*' /etc/docker/daemon.json 2>/dev/null| Result | When | What it means |
|---|---|---|
| PASS | The daemon listens on the local Unix socket only | No network exposure. Access requires local socket permissions. |
| WARN | The exposed endpoint includes port 2376 | TLS is implied by the port, not proven by the check. Verify tlsverify and client certificates are actually enforced. |
| FAIL | The daemon is exposed over TCP on any other port, 2375 included | Anyone who can reach the port owns the host as root. Remove the -H tcp binding. |
| SKIP | Docker is not installed | Nothing to check. |
The WARN on 2376 is the branch people misread. It is not a statement that your TLS is correct. It is a statement that the audit found the conventional TLS port and cannot verify the rest from outside, so you have to.
How to fix it
sudo systemctl edit docker.service[Service]
ExecStart=
ExecStart=/usr/bin/dockerd -H unix:///var/run/docker.sockIf the binding is in daemon.json, remove the TCP entry from hosts. If remote access is genuinely required, bind 2376 with mutual TLS:
{
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"],
"tls": true,
"tlsverify": true,
"tlscacert": "/etc/docker/ca.pem",
"tlscert": "/etc/docker/server-cert.pem",
"tlskey": "/etc/docker/server-key.pem"
}Then sudo systemctl restart docker.
Check 3: Is the Docker socket mounted inside any container?
Mounting /var/run/docker.sock into a container gives that container full control of the daemon, and therefore full root on the host. It is functionally equivalent to --privileged. A read-only mount does not help: the socket is a Unix domain socket, not a file, and ro restricts filesystem operations on the mount point rather than what you can send down the socket.
The audit inspects the mounts of every running container and matches both spellings, /var/run/docker.sock and /run/docker.sock, because /var/run is a symlink to /run on every systemd host and docker inspect reports whichever path the container was started with.
How to check manually
docker ps -q | xargs docker inspect \
--format '{{$n:=.Name}}{{range .Mounts}}{{if eq .Source "/var/run/docker.sock" "/run/docker.sock"}}{{$n}}{{end}}{{end}}' | tr -d '/'| Result | When | What it means |
|---|---|---|
| PASS | No running container mounts the Docker socket | Socket access stays on the host. |
| FAIL | One or more containers have docker.sock mounted | Those containers are named. Each one is root on the host, and a read-only mount does not change that. |
| SKIP | The daemon is not reachable, or no containers are running | Nothing to evaluate. |
CI runners, monitoring agents and deployment utilities ask for this mount routinely. Every one of them is a root-equivalent attack surface, which is why the check fails rather than warns even when the container in question is something you installed deliberately.
How to fix it
Remove the mount and put a filtered proxy in front of the API:
services:
docker-proxy:
image: tecnativa/docker-socket-proxy
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
CONTAINERS: 1
IMAGES: 0
EXEC: 0
your-tool:
environment:
DOCKER_HOST: tcp://docker-proxy:2375The proxy still holds the socket, so put it on its own network and enable only the API endpoints the tool actually calls. If the tool cannot work through a proxy, the question becomes whether it should run on a host that matters.
:ro flag is false comfort. A container with a read-only docker.sock mount can still run docker exec, docker run --privileged and every other daemon command. Read-only applies to the mount point, not to the protocol spoken over it.Check 4: Who is in the docker group?
Every user in the docker group can talk to the daemon socket without sudo, which makes every member root-equivalent on the host. One docker run -v /:/host --privileged alpine chroot /host gives them the entire filesystem as root. Adding a developer to the docker group is usually treated as a convenience. It is a root grant with no audit trail.
The audit reads the group's member list and reports every name. There is no threshold to tune, because the correct number depends on people, not on configuration.
How to check manually
getent group docker | cut -d: -f4| Result | When | What it means |
|---|---|---|
| PASS | No users are in the docker group | Socket access stays with root. |
| WARN | One or more users are in the docker group | Every member is listed by name. Each one is root-equivalent through the socket, so confirm every name still belongs there. |
This check has no SKIP branch. getent group docker answers on any host, including one where Docker was uninstalled and the group was left behind, which is itself worth knowing.
How to fix it
sudo gpasswd -d username dockerThe user has to log out and back in for the change to take effect. For developers who genuinely need to build and run containers, the alternatives are rootless Docker, Podman, or a narrow sudo rule:
# /etc/sudoers.d/docker-limited
username ALL=(root) NOPASSWD: /usr/bin/docker build *, /usr/bin/docker compose *Check 5: Is the daemon log level set to debug?
Debug logging records every API request, including request bodies. That can put environment variables, build arguments and registry credentials into the daemon log, where they are readable by anyone who can read the journal and are shipped wholesale to whatever collects your logs.
The audit reads the --log-level flag from the dockerd process first, then falls back to log-level in /etc/docker/daemon.json. Anything other than debug passes, including the default when nothing is set at all.
How to check manually
ps -eo args | grep '[d]ockerd' | grep -oE '\-\-log-level[= ]+[a-z]+'
grep 'log-level' /etc/docker/daemon.json 2>/dev/null| Result | When | What it means |
|---|---|---|
| PASS | The log level is info, another non-debug value, or unset | The effective level is named in the finding, including "info (default)" when nothing is configured. |
| WARN | The daemon runs at debug level | Noisy logs that can carry sensitive request data. Set it back to info. |
| SKIP | Docker is not installed | Nothing to check. |
How to fix it
{
"log-level": "info"
}Or drop the --log-level debug flag from the systemd unit, then sudo systemctl restart docker.
Check 6: Does the Docker daemon run in rootless mode?
Standard Docker runs the daemon as root, so a container escape lands the attacker as root on the host. Rootless mode runs the daemon and the containers under an unprivileged user, so the same escape lands as that user instead.
The audit reads docker info security options and looks for rootless. A standard root daemon is reported as a warning rather than a failure, because it is the default installation and rootless mode is genuinely not compatible with every workload.
How to check manually
docker info --format '{{.SecurityOptions}}' | grep -o rootless| Result | When | What it means |
|---|---|---|
| PASS | The daemon reports rootless in its security options | A container escape lands as an unprivileged user. |
| WARN | The daemon runs as root, the standard setup | Rootless mode or Podman shrinks the blast radius of an escape. The warning asks you to evaluate whether your workloads allow it, not to switch blindly. |
| SKIP | The Docker daemon is not reachable | Nothing to check. |
This is the one LOW severity check on this page that describes the strongest available control. The severity reflects how often the warning is the right answer to live with, not how much rootless mode protects you when it fits.
How to fix it
sudo apt-get install -y uidmap dbus-user-session
# As the target unprivileged user, NOT as root
dockerd-rootless-setuptool.sh install
echo 'export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock' >> ~/.bashrcVerify with docker info --format '{{.SecurityOptions}}' | grep rootless.
/var/run/docker.sock needs its DOCKER_HOST changed. If those constraints rule it out, user namespace remapping is the weaker fallback that works with a root daemon.Check 7: Is user namespace remapping configured?
User namespace remapping maps container UID 0 to an unprivileged UID on the host, typically somewhere above 100000. Without it, root inside a container is root outside the container the moment anything escapes. With it, the same escape lands as a user with no privileges anywhere.
This is the daemon-level fallback for containers that cannot run as a non-root user, and it works with the standard root daemon. The audit reads the --userns-remap argument from the dockerd process, then userns-remap in /etc/docker/daemon.json, and treats an empty value as not configured.
How to check manually
ps -eo args | grep '[d]ockerd' | grep -oE '\-\-userns-remap[= ][^ ]*'
grep 'userns-remap' /etc/docker/daemon.json 2>/dev/null| Result | When | What it means |
|---|---|---|
| PASS | userns-remap is configured with a non-empty value | Container root maps to an unprivileged host uid, so an escape does not land as host root. |
| WARN | userns-remap is not configured, or is set to an empty value | Root inside a container is root on the host on escape. Set it in /etc/docker/daemon.json unless a workload needs host uids. |
| SKIP | Docker is not installed | Nothing to check. |
This check and check 3 of the Container Hardening audit are linked: a root container on a remapped daemon is a warning there, and a root container on a non-remapped daemon is a failure. Fixing this one downgrades the other.
How to fix it
{
"userns-remap": "default"
}default creates a dockremap user and configures subordinate UID and GID ranges automatically. Restart the daemon, then verify:
grep dockremap /etc/subuid
docker run --rm alpine id--userns=host per container for the few workloads that need real host uids rather than turning the remap off globally.Check 8: Are container logs capped with rotation limits?
The default json-file log driver writes container stdout and stderr to files under /var/lib/docker/containers. With no max-size, those files grow until the disk fills, and a full disk takes down every service on the host, Docker included. It is a denial of service that requires no attacker, only a chatty container and enough time.
The audit reads log-driver and max-size from /etc/docker/daemon.json, treating an unset driver as json-file because that is Docker's default.
daemon.json, so it can warn on a host that is doing it right. Per-container --log-opt max-size flags and Compose logging: blocks are invisible to it. If your containers cap their own logs, confirm with docker inspect --format '{{.HostConfig.LogConfig}}' <container> and treat the warning as a false positive. A daemon-wide default is still worth setting as a backstop for the containers that do not. The known issues page lists this and the other checks whose logic has drifted.How to check manually
grep -E '"log-driver"|"max-size"|"max-file"' /etc/docker/daemon.json 2>/dev/null| Result | When | What it means |
|---|---|---|
| PASS | The json-file driver has a max-size configured, or a non-json-file driver is in use | Two distinct passes. With json-file the configured max-size is named; with syslog, journald or fluentd the finding says rotation is that driver responsibility and asks you to confirm retention on the receiving end. |
| WARN | The json-file driver is in use with no max-size | Container logs grow without bound until the disk fills. Set max-size and max-file in /etc/docker/daemon.json. |
| SKIP | Docker is not installed | Nothing to check. |
How to fix it
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}That caps each container at 30 MB of logs. Restart the daemon, then recreate the containers, because existing ones keep the log options they were created with:
sudo systemctl restart docker
docker compose down && docker compose up -dWhat this audit does not cover
This audit reads the daemon configuration and host-level controls. It does not check:
- Container-level hardening. Privileged mode, capabilities, non-root users and read-only filesystems on running containers are the Container Hardening audit.
- Image-level security. Base image CVEs, embedded secrets and floating tags are the Image Vulnerabilities audit.
- Runtime limits. Seccomp, AppArmor, memory, CPU and PID caps are the Runtime and Resources audit.
- Volume mount safety. Sensitive host paths, world-writable volumes and dangling volumes are the Volume Permissions audit.
- Kubernetes cluster security. RBAC, network policies and pod security standards are a different surface entirely.
The manual audit problem at scale
Running these eight checks by hand takes about five minutes per host, and most of that is not typing. It is remembering that the TCP binding can be in three places, that the socket mount has two spellings, and that userns-remap set to an empty string is the same as not set at all.
On ten servers that is fifty minutes. The consistency problem is worse here than for container checks, because daemon configuration is spread across systemd overrides, daemon.json and command-line flags, and those sources can contradict each other. Missing one source means missing the finding.
| Task | By hand, 8 hosts | CtrlOps, 8 hosts |
|---|---|---|
| Run all 8 checks | About 40 minutes | About 95 seconds |
| Config sources checked | Manual, easy to miss one | Process args, daemon.json and systemd, every time |
| Get a scored report | Not available | Automatic |
| Fix what failed | Copy-paste from your notes | AI-generated commands behind an approval gate |
| Re-audit after fixing | Another 40 minutes | Another 95 seconds |
How CtrlOps runs all 8 checks in one click
Instead of running these commands on every server by hand, CtrlOps runs the whole Daemon and Socket 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 Daemon & Socket
Choose it from the Docker category. All 8 checks are listed with their descriptions, severity levels and the estimated run time, about 12 seconds in total. Three of them need 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 with the specific ports, container names and group members attached
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 on this audit in particular: several of these fixes restart the Docker daemon, which stops every container on the host that is not set to restart.
Step 6: Re-run and compare
Run the same audit again after applying the fixes and watch the score move. Checks 2 and 4 are the two worth re-running on a schedule, because a TCP binding added for a one-off deployment and a name added to the docker group are both changes nobody remembers making.
Conclusion
Everything in the Docker category assumes the daemon is not already handing out root, and this is the audit that tests the assumption. A TCP socket on 2375, a docker.sock mount in a CI runner and a stale docker group membership are the same finding written three ways: someone who should not have root on this host has it.
The CtrlOps Security Audit runs all 8 checks in about 12 seconds per server, agentlessly, and reads all three configuration sources every time.
The neighbouring checklists cover the other container layers: container hardening, image vulnerabilities, volume permissions, runtime and resources and network and supply chain.
Frequently asked questions
docker.sock is the Unix socket the Docker daemon listens on. Any process that can write to it can create privileged containers, bind-mount the host filesystem and execute commands as root. The daemon performs no authorization beyond filesystem permissions on the socket itself, which is why socket access and root access are the same thing.
It is safer than 2375, but only if tlsverify is enabled and client certificates are actually distributed and enforced. Without mutual verification, TLS protects the transport and authenticates nobody. The audit warns on 2376 rather than passing it, because the port number tells you the intent and nothing about the configuration.
Run sudo gpasswd -d username docker, then have the user log out and back in. Verify with getent group docker. If they still need to build and run containers, rootless Docker or Podman gives them that without a root-equivalent group membership.
Rootless mode runs the daemon and containers as an unprivileged user, so a container escape lands as that user rather than as root. Use it when your workloads do not need ports below 1024, host networking with privileged capabilities, or direct access to the system socket. When they do, user namespace remapping is the weaker fallback that works with a standard root daemon.
Yes, from three sources: listening ports via ss, the dockerd process arguments, and the hosts array in /etc/docker/daemon.json. It distinguishes unauthenticated TCP, which fails, from port 2376, which warns and tells you to verify TLS enforcement yourself.
It maps container UID 0 to an unprivileged host UID, so root inside a container is not root on the host. Add "userns-remap": "default" to /etc/docker/daemon.json and restart the daemon. Existing containers and volumes need recreating afterwards, because the mapping changes file ownership.
Set "log-driver": "json-file" with "log-opts": {"max-size": "10m", "max-file": "3"} in /etc/docker/daemon.json, restart Docker, then recreate existing containers so they pick up the new options. Containers keep whatever log configuration they were created with, which is why a daemon restart alone does not fix the finding. Note that the check reads daemon.json only, so per-container limits set in a Compose logging: block still produce a warning.
Because a root daemon is the default installation, not a mistake. The warning is a prompt to evaluate whether rootless mode fits your workloads, and the severity is LOW precisely because living with the warning is often the right call. What it should not be is the answer nobody ever looked at.
Check 1 detects Podman and says so explicitly. The rest target the Docker daemon and report skips, because there is no daemon to audit. That is the correct result: Podman is daemonless and rootless-friendly, which removes most of what checks 3, 6 and 7 are looking for in the first place.
Check 2 if it fails, then check 3, then check 4. Those three are the only ways daemon access leaks to somebody who should not have it, and each of them is root on the host rather than a step towards it. The remaining five reduce the damage of a compromise rather than preventing access.