Docker Security Basics
Docker security is about reducing what a container can do if the application, dependency, or image is compromised. Containers are isolated processes, not tiny virtual machines, so safe defaults matter: use trustworthy images, run as a non-root user, remove unneeded privileges, and avoid putting secrets into images.
A secure Docker setup does not make vulnerable code invulnerable. It limits blast radius, makes mistakes easier to detect, and keeps common container problems from becoming host problems.
Overview: How Docker Security Works
A Docker image is a read-only template made from stacked filesystem layers plus metadata. A container is an instance of that image with a thin writable layer, mounts, network settings, resource controls, and one main process. Security decisions apply at both levels: what is inside the image, and what permissions the runtime grants to the container.
On Linux, Docker isolation is built mostly from kernel features. Namespaces give the container its own view of processes, networking, users, mounts, and hostnames. Cgroups limit resources such as memory, CPU, and process count. Capabilities split root privileges into smaller pieces, so a container can be root inside its namespace without automatically receiving every powerful host operation. Seccomp filters restrict system calls, and Linux security modules such as AppArmor or SELinux can add another policy layer.
Docker Desktop on macOS and Windows runs Linux containers inside a Linux virtual machine. That VM adds a boundary from the host operating system, but the same container model still applies inside the VM. On Linux Docker Engine, containers run directly on the Linux host kernel, so daemon access should be treated as highly privileged. A user who can control the Docker daemon can often mount host paths, start privileged containers, or read sensitive host files.
Security also starts before docker run. Image tags are mutable unless pinned by digest, so latest is a moving target. Large images include more packages and therefore more possible vulnerabilities. Dockerfile instructions create layers; if a secret is copied or placed in ENV, deleting it later does not reliably remove it from previous layers or image history. Registries store image manifests that point to layer blobs and platform-specific image data, so every pushed layer should be safe to share with the people who can pull that image.
The practical goal is defense in depth. Use a minimal image, pin versions, scan images where your tooling supports it, keep the Dockerfile clean, run as a non-root user, mount only the files the app needs, publish only required ports, drop capabilities, avoid --privileged, and use secrets or mounted files for sensitive values.
Syntax
docker run [OPTIONS] IMAGE[:TAG] [COMMAND] [ARG...]
| Option | Purpose |
|---|---|
--user UID:GID |
Runs the process as a specific user and group instead of root. |
--read-only |
Makes the container root filesystem read-only. Add explicit writable mounts for paths that need writes. |
--tmpfs PATH |
Provides a temporary in-memory writable filesystem at a path such as /tmp or /var/run. |
--cap-drop ALL |
Removes Linux capabilities from the container. Add back only what the app truly needs. |
--security-opt no-new-privileges |
Prevents the process from gaining additional privileges through setuid binaries or similar mechanisms. |
--pids-limit N |
Limits the number of processes inside the container. |
--memory SIZE |
Limits memory, helping prevent a runaway process from exhausting the host. |
-p HOST:CONTAINER |
Publishes a container port to the host. Dockerfile EXPOSE is only metadata; it does not publish a port. |
Dockerfile security usually uses this form:
FROM IMAGE:SPECIFIC_TAG
WORKDIR /app
COPY dependency-files ./
RUN install-dependencies
COPY application-files ./
USER nonroot-user
CMD ["executable", "arg"]
The order matters. Docker caches each layer; changing a line invalidates that layer and every layer after it. Copy dependency manifests and install dependencies before copying changing source files. This improves build speed and avoids repeatedly rebuilding layers that do not need to change.
Examples
Example 1: See Why Containers Often Start as Root
docker run --rm alpine:3.20 id
docker run --rm --user 1000:1000 alpine:3.20 id
Output:
uid=0(root) gid=0(root) groups=0(root)
uid=1000 gid=1000 groups=1000
The first container runs as root because the image does not choose a different default user. Root inside a container is constrained by namespaces and runtime policy, but it is still too much power for many applications. The second command starts the same image as UID 1000 and GID 1000. If the process only needs to read files and listen on an unprivileged port, this is usually enough.
Example 2: Build an App Image That Does Not Run as Root
FROM node:20-alpine
WORKDIR /app
RUN addgroup -S app && adduser -S -G app app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
USER app
EXPOSE 3000
CMD ["node", "server.js"]
Build it with a specific tag:
docker build -t my-node-api:1.0 .
Output:
Successfully tagged my-node-api:1.0
This Dockerfile pins the base image to node:20-alpine instead of using node or latest. It creates a dedicated application user and switches to that user before the final command. EXPOSE 3000 documents the intended container port, but it does not publish anything to the host; you still need -p or Compose ports: when running it.
Example 3: Run a Container with Fewer Runtime Permissions
docker run -d --name secure-nginx \
--read-only \
--tmpfs /var/cache/nginx \
--tmpfs /var/run \
--cap-drop ALL \
--security-opt no-new-privileges \
--pids-limit 100 \
--memory 128m \
-p 8080:80 \
nginx:1.27-alpine
Output:
4f2c9a6b8f7d0d4e5a1b2c3d4e5f67890123456789abcdef0123456789abcdef
This starts Nginx with a read-only root filesystem and grants writable temporary filesystems only where Nginx needs runtime files. It drops all Linux capabilities and blocks privilege escalation. The memory and process limits are not a complete security boundary, but they reduce damage from runaway code. The -p 8080:80 flag is what publishes the service to the host.
Example 4: Inspect Image Metadata Before You Trust It
docker image inspect nginx:1.27-alpine --format '{{.Config.User}}'
docker history --no-trunc nginx:1.27-alpine
Output:
nginx
IMAGE CREATED BY SIZE COMMENT
sha256:... CMD ["nginx" "-g" "daemon off;"] 0B
sha256:... EXPOSE map[80/tcp:{}] 0B
The first command checks the configured default user. Empty output would mean Docker defaults to root. The history command shows layer commands and metadata. Do not rely on history as a full vulnerability scanner, but use it to catch obvious problems such as secrets in ENV, broad package installs, or unexpected commands.
How It Works Step by Step
- You run
docker run. The Docker client sends the request to the Docker daemon. - The daemon resolves the image tag, pulls missing layers from a registry if needed, and creates a thin writable container layer above the read-only image layers.
- Docker prepares namespaces, cgroups, mounts, network interfaces, port publishing rules, and security policy for the container process.
- If you set
--user, Docker starts the process with that UID and GID. If you do not, it uses the image default, often root. - If you set
--read-only, writes to the image filesystem fail unless the path is covered by a writable volume, bind mount, or--tmpfs. - If you set
--cap-drop ALL, Docker removes Linux capabilities from the process. Capability-sensitive operations then fail instead of silently succeeding. - The application starts. If it is compromised, the attacker is limited by the filesystem, user, capabilities, mounts, network exposure, and daemon-level policy you chose.
This is why Docker security is mostly about defaults. You are not adding one magic flag; you are making the container’s allowed behavior match the application’s real needs.
Common Mistakes
Using latest in Production
FROM node:latest
COPY . .
RUN npm install
CMD ["node", "server.js"]
This is wrong because latest can point to a different image tomorrow. That breaks reproducibility and makes security findings harder to trace. Use a specific tag such as node:20-alpine, and for high-control deployments consider pinning by digest.
Baking Secrets into an Image
FROM alpine:3.20
ENV API_TOKEN=<YOUR_API_TOKEN>
RUN echo "ready" > /status.txt
This is wrong because ENV becomes image metadata. If a secret is copied into a layer, committed through ENV, or passed as a normal build argument, deleting it later does not reliably remove it from earlier layers. Use runtime secrets, mounted files, or BuildKit --secret for build-time credentials.
Running Everything as Privileged
docker run --rm --privileged -v /:/host alpine:3.20 sh
This gives the container broad host-level power and mounts the host root filesystem. It is useful for a few low-level administrative tools, but it is not a normal application setting. Remove --privileged, mount only the specific path needed, and prefer read-only mounts where possible.
Assuming EXPOSE Publishes a Port
FROM nginx:1.27-alpine
EXPOSE 80
EXPOSE is documentation and image metadata only. It tells readers and tools which port the app expects, but it does not open the host firewall or publish a port. Use docker run -p 8080:80 nginx:1.27-alpine or Compose ports: to publish.
Best Practices
- Use trusted images from known publishers, and prefer small base images with only the packages you need.
- Pin specific image tags. Avoid
latestfor production builds because it moves without changing your Dockerfile. - Run application processes as a non-root user with
USERin the Dockerfile or--userat runtime. - Drop unneeded capabilities with
--cap-drop ALL, then add back only a capability the app demonstrably requires. - Use
--read-onlywith explicit volumes or--tmpfspaths for writable runtime data. - Never bake secrets into Dockerfiles, image layers, labels, logs, or committed env files.
- Publish only required ports. Remember that
EXPOSEdoes not publish anything by itself. - Limit process and memory usage for services that could fork too many processes or allocate too much memory.
- Keep
.dockerignorestrict so the build context does not include credentials, Git history, node_modules, test data, or local artifacts. - Treat Docker daemon access as administrator-level access, especially on Linux hosts.
Practice Exercises
- Take a simple web image you have built earlier in the course and modify its Dockerfile so the final process runs as a non-root user. Expected end state:
docker run --rm IMAGE idprints a UID other than 0. - Run
nginx:1.27-alpinewith a read-only root filesystem, dropped capabilities, a process limit, and port8080published to container port80. Hint: Nginx needs temporary writable paths. - Inspect one image you commonly use. Look at its configured user and layer history. Expected end state: write down whether it defaults to root and whether the history shows anything surprising.
Summary
- Containers are isolated processes using kernel features, not full virtual machines.
- Security depends on both image contents and runtime permissions.
- Run as non-root, drop capabilities, avoid privilege escalation, and make filesystems read-only when practical.
- Use pinned image tags and keep build contexts clean with
.dockerignore. - Never put secrets in image layers, Dockerfile
ENV, normal build arguments, or logs. EXPOSEis metadata only; publishing requires-por Composeports:.- Anyone who controls the Docker daemon should be treated as highly privileged.
