Docker security checklist

Docker Daemon & Socket Security Checklist: 8 Checks (2026)

Anything that can talk to the Docker daemon has root on the host. This page documents all 8 checks in the CtrlOps Daemon and Socket 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, 202616 min read
8
checks in this audit
2
rated high severity
12s
automated run time
3
need root or sudo

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

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.json and /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.json and command-line flags. Checks 2, 5 and 7 read all of them, because missing one source means missing the finding.
#CheckSeverityWhat it catchesRoot needed
1Docker inventoryLOWEngine version, runc version, container and image countsYes
2Docker TCP socketHIGHA daemon reachable over the network on 2375 or 2376No
3Socket in containersHIGHdocker.sock bind-mounted into a containerYes
4Docker group membersMEDIUMAccounts that are root-equivalent through the socketNo
5Daemon log levelLOWDebug logging that records request bodiesNo
6Rootless modeLOWA root daemon where rootless would workYes
7User namespace remappingMEDIUMContainer root mapping to host rootNo
8Container log limitsLOWUnbounded json-file logs that can fill the diskNo
Podman hosts report skips, not failures. Check 1 detects Podman explicitly and says so. The remaining checks target the Docker daemon, and a daemonless runtime has no daemon to audit. That is a correct result rather than a gap: Podman's rootless-by-default model removes most of what checks 3, 6 and 7 look for.

Check 1: What Docker engine and runtime versions are installed?

Low severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSThe daemon is reachable and reports its versionsInformational. The engine version, runc version, running and total container counts and image count are recorded in the finding.
SKIPDocker is not installed, Podman is installed instead, or the daemon is installed but unreachableThree 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 reachable
Bottom line: note your runc version. Below 1.1.12 the host is exposed to CVE-2024-21626, the Leaky Vessels escape. runc can be updated independently of the Docker package when the distribution lags behind.

Check 2: Is the Docker daemon exposed over TCP?

High severityNo sudo needed

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 thresholds this check applies
ResultWhenWhat it means
PASSThe daemon listens on the local Unix socket onlyNo network exposure. Access requires local socket permissions.
WARNThe exposed endpoint includes port 2376TLS is implied by the port, not proven by the check. Verify tlsverify and client certificates are actually enforced.
FAILThe daemon is exposed over TCP on any other port, 2375 includedAnyone who can reach the port owns the host as root. Remove the -H tcp binding.
SKIPDocker is not installedNothing 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.sock

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

Assume it has already been found. Exposed Docker daemons on 2375 are indexed continuously by internet-wide scanners, and cryptominer campaigns have used them as a primary entry point for years. If yours is on TCP right now, treat the host as probed and check what is running on it before you close the port.

Check 3: Is the Docker socket mounted inside any container?

High severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSNo running container mounts the Docker socketSocket access stays on the host.
FAILOne or more containers have docker.sock mountedThose containers are named. Each one is root on the host, and a read-only mount does not change that.
SKIPThe daemon is not reachable, or no containers are runningNothing 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:2375

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

The :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?

Medium severityNo sudo needed

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 thresholds this check applies
ResultWhenWhat it means
PASSNo users are in the docker groupSocket access stays with root.
WARNOne or more users are in the docker groupEvery 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 docker

The 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 *
Bottom line: audit this group the way you audit sudoers, on a schedule and at offboarding. A former team member left in the docker group is a forgotten root shell, and nothing on the host will ever flag it.

Check 5: Is the daemon log level set to debug?

Low severityNo sudo needed

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 thresholds this check applies
ResultWhenWhat it means
PASSThe log level is info, another non-debug value, or unsetThe effective level is named in the finding, including "info (default)" when nothing is configured.
WARNThe daemon runs at debug levelNoisy logs that can carry sensitive request data. Set it back to info.
SKIPDocker is not installedNothing 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.

Bottom line: debug logging belongs to a troubleshooting session, not to a running configuration. Turn it on when you need it, turn it off when the session ends, and check this finding after any incident where someone was debugging the daemon.

Check 6: Does the Docker daemon run in rootless mode?

Low severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSThe daemon reports rootless in its security optionsA container escape lands as an unprivileged user.
WARNThe daemon runs as root, the standard setupRootless 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.
SKIPThe Docker daemon is not reachableNothing 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' >> ~/.bashrc

Verify with docker info --format '{{.SecurityOptions}}' | grep rootless.

Know what breaks before you migrate. Rootless mode cannot bind ports below 1024 without extra configuration, some storage drivers behave differently, and anything that expects /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?

Medium severityNo sudo needed

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 thresholds this check applies
ResultWhenWhat it means
PASSuserns-remap is configured with a non-empty valueContainer root maps to an unprivileged host uid, so an escape does not land as host root.
WARNuserns-remap is not configured, or is set to an empty valueRoot inside a container is root on the host on escape. Set it in /etc/docker/daemon.json unless a workload needs host uids.
SKIPDocker is not installedNothing 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
Existing containers and volumes must be recreated. The remap changes file ownership under the hood, so data written before the change is owned by uids the remapped containers no longer are. Test in staging, and use --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?

Low severityNo sudo needed

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.

This check reads only 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 thresholds this check applies
ResultWhenWhat it means
PASSThe json-file driver has a max-size configured, or a non-json-file driver is in useTwo 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.
WARNThe json-file driver is in use with no max-sizeContainer logs grow without bound until the disk fills. Set max-size and max-file in /etc/docker/daemon.json.
SKIPDocker is not installedNothing 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 -d
Bottom line: 10m with 3 files is a sensible default. A host running twenty containers then uses at most 600 MB for logs. Without it, one container in a crash loop can fill a 50 GB disk in hours, which is also why the restart policy check exists.

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

TaskBy hand, 8 hostsCtrlOps, 8 hosts
Run all 8 checksAbout 40 minutesAbout 95 seconds
Config sources checkedManual, easy to miss oneProcess args, daemon.json and systemd, every time
Get a scored reportNot availableAutomatic
Fix what failedCopy-paste from your notesAI-generated commands behind an approval gate
Re-audit after fixingAnother 40 minutesAnother 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.

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