Most Docker security issues are not exotic container escapes; they are ordinary misconfigurations that ship straight to production because nobody looked. A container inherits every problem in its base image, every secret you accidentally left in a layer, and every privilege you granted the runtime. Fixing these is less about clever tooling and more about defaults that fail safe.
This guide walks through the categories that actually cause incidents, with the commands and Dockerfile changes to address each.
Vulnerable base images
The single largest source of Docker security issues is the base image. When you write FROM node:18, you inherit hundreds of OS packages, most of which your app never calls but all of which count against your attack surface. A stale debian:bullseye layer can carry openssl or glibc CVEs for months.
Two habits help. First, prefer minimal images: node:18-slim, python:3.12-slim, or distroless images that ship no shell or package manager. Second, scan the image you actually build, not the tag you think you pulled:
docker build -t myapp:latest .
trivy image myapp:latest
Trivy, Grype, and Docker Scout all report OS and language-package CVEs. The point is to scan on every build in CI so a newly disclosed vulnerability in a pinned dependency fails the pipeline rather than surfacing in a pen test.
Running containers as root
By default a container process runs as UID 0. If an attacker exploits your app, root inside the container plus a kernel bug or a careless bind mount can mean root on the host. There is rarely a reason for this.
Add a non-root user in the Dockerfile and drop to it:
RUN addgroup --system app && adduser --system --ingroup app app
USER app
At runtime, reinforce it and strip Linux capabilities you do not need:
docker run --user 1000:1000 \
--cap-drop=ALL --cap-add=NET_BIND_SERVICE \
--read-only --security-opt=no-new-privileges myapp:latest
--read-only mounts the root filesystem read-only, which blocks a whole class of tampering, and no-new-privileges stops setuid binaries from escalating.
Secrets baked into image layers
A classic mistake: COPY .env . or ARG API_KEY followed by using that value at build time. Docker layers are content-addressed and cached, so a secret written into any layer stays recoverable with docker history or by extracting the tarball, even if a later layer deletes the file.
Never bake secrets into images. Use BuildKit secret mounts for build-time credentials, which never persist in a layer:
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
For runtime secrets, inject them through the orchestrator (Kubernetes Secrets, a vault sidecar, or environment variables set at deploy) rather than the image. Scan built images for leaked credentials with tools like trufflehog as a backstop.
The Docker daemon and socket exposure
The Docker daemon runs as root, and access to /var/run/docker.sock is effectively root on the host. Two patterns cause trouble here. Mounting the socket into a container (-v /var/run/docker.sock:/var/run/docker.sock) gives that container full control of the daemon, so a compromise of a CI runner or a monitoring agent becomes a host compromise. And exposing the daemon over TCP without TLS (-H tcp://0.0.0.0:2375) hands the host to anyone who can reach the port.
If you must grant Docker access, use a rootless daemon or a brokered proxy that whitelists specific API calls. Treat any container with the socket mounted as a privileged trust boundary.
Overly permissive networking and volumes
Bind-mounting host paths (-v /:/host) or running with --privileged collapses the isolation Docker is supposed to give you. --privileged disables seccomp, AppArmor, and capability restrictions all at once; it is almost never justified outside of building images or running nested Docker in controlled CI.
Be deliberate about published ports, too. -p 5432:5432 on a database container binds to all host interfaces by default. Bind to localhost (-p 127.0.0.1:5432:5432) unless the service genuinely needs external reach, and put internal services on a user-defined bridge network so they talk to each other without exposing ports at all.
Unpinned tags and supply chain drift
FROM ubuntu:latest means your build is not reproducible and can silently pull a different image tomorrow. Pin by digest for anything that matters:
FROM ubuntu:24.04@sha256:<digest>
Beyond the base image, the dependencies your app installs are their own supply chain. A container image is only as trustworthy as the packages layered on top, and vulnerabilities in transitive dependencies do not show up in the Dockerfile at all. Generating an SBOM for each image and tracking it over time closes that gap; an SCA tool such as Safeguard can flag a vulnerable transitive package inside an image before it reaches a registry. Signing images with cosign and verifying signatures at deploy time prevents tampered images from running.
A practical hardening checklist
Roll the fixes above into a baseline every image should meet:
- Minimal or distroless base image, pinned by digest
- Non-root
USER, dropped capabilities,no-new-privileges - Read-only root filesystem where feasible
- No secrets in layers; BuildKit secret mounts for build-time creds
- Image and SBOM scanned in CI, build fails on critical CVEs
- No socket mounts or
--privilegedunless justified and reviewed - Ports bound to localhost unless external access is required
None of this requires a new platform. It requires making the safe option the default in your Dockerfiles and CI templates so that developers get hardened containers without thinking about it. If you want to go deeper on scanning workflows, the Safeguard Academy has hands-on material.
FAQ
What is the most common Docker security issue?
Running containers as root combined with vulnerable base images. Both are defaults you have to opt out of, so they show up constantly. Adding a non-root USER and switching to a slim or distroless base removes the two biggest risks with a few lines of Dockerfile.
Does scanning an image catch all Docker security issues?
No. Image scanning finds known CVEs in OS and language packages, which is essential but partial. It does not catch misconfigurations like --privileged, socket mounts, exposed ports, or secrets you injected at runtime. Pair scanning with runtime and configuration checks.
Is --privileged ever safe to use?
Rarely. It disables seccomp, AppArmor, and capability limits at once, so a container breakout becomes trivial. Legitimate uses are narrow, such as building images inside a controlled CI job. For normal workloads, grant only the specific capabilities you need with --cap-add.
How do I keep secrets out of Docker images?
Use BuildKit secret mounts for anything needed at build time, since those never persist in a layer, and inject runtime secrets through your orchestrator or a vault rather than the image. Scan built images with a secret-detection tool as a safety net.