Docker Course
Docker Security Basics
A security researcher once pulled a popular Docker image from a public registry, ran it, and found an SSH backdoor baked into one of the layers. The image had over 10 million pulls. The maintainer had been compromised months earlier. Container security isn't paranoia — it's the acknowledgment that every image you pull is code you're running on your infrastructure, written by someone you've never met.
This lesson covers the security mistakes hiding in the average Docker setup — and the fixes that take minutes to implement but meaningfully reduce your attack surface. Not theory. Not compliance checkbox security. Practical changes that make a real difference.
The Unsecured vs Secured Setup
Typical "it works" setup
- Running as root inside the container
- Base image pulled months ago, never updated
- All capabilities granted to the process
- No resource limits — one container starves the host
- Secrets baked into image layers
- Writable root filesystem
- No vulnerability scanning on images
Production-hardened setup
- Non-root user with minimal permissions
- Base image pinned and regularly rebuilt
- Capabilities dropped — only what's needed
- CPU and memory limits enforced
- Secrets passed at runtime, never in image
- Read-only root filesystem where possible
- Automated scanning in the CI pipeline
Security Fix 1 — Run as a Non-Root User
Containers run as root by default. If an attacker exploits a vulnerability in your application and gets code execution, they land as root inside the container — able to install tools, read any file, modify any configuration, and in some configurations escalate to the host. The fix is one instruction.
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
RUN addgroup -S appgroup && \
adduser -S appuser -G appgroup && \
chown -R appuser:appgroup /app
# System group and user — no password, no login shell
# Transfer /app ownership so appuser can read its own files
USER appuser
# All subsequent instructions and the container process run as appuser
# uid ~100, not uid 0 (root)
EXPOSE 3000
CMD ["node", "server.js"]
docker exec payment-api whoami appuser docker exec payment-api id uid=100(appuser) gid=101(appgroup) groups=101(appgroup) # Attempt a privileged operation docker exec payment-api apt-get install curl /bin/sh: apt-get: Permission denied
What just happened?
The process runs as uid 100 — not uid 0. An attacker who gets code execution lands as appuser and immediately hits a wall — cannot install packages, cannot read files outside /app, cannot touch system files, cannot interact with Docker directly. The blast radius of any vulnerability is dramatically contained by one Dockerfile instruction that takes 30 seconds to add. It's the highest-impact security improvement available to any container image.
Security Fix 2 — Read-Only Root Filesystem
A writable root filesystem lets an attacker install persistence tools, modify binaries, and generally make a breach much worse. Most web services never need to write to their own container filesystem. Make it read-only — then provide writable in-memory paths for the temporary files the app legitimately needs.
docker run -d \
--name payment-api \
--read-only \
--tmpfs /tmp \
--tmpfs /app/tmp \
-p 3000:3000 \
payment-api:v1.2.0
# --read-only → root filesystem is read-only — no writes anywhere
# --tmpfs /tmp → in-memory writable mount at /tmp
# disappears on container stop, never touches disk
# --tmpfs /app/tmp → additional tmpfs for app-specific temp needs
# Attacker attempts to write a backdoor: docker exec payment-api touch /usr/local/bin/backdoor touch: /usr/local/bin/backdoor: Read-only file system # Legitimate temp file still works: docker exec payment-api touch /tmp/session-cache.tmp (success)
What just happened?
Writing a backdoor binary to /usr/local/bin failed instantly. The --tmpfs mounts give the app legitimate writable space without compromising the read-only constraint. Since tmpfs lives entirely in RAM and disappears on container stop, any files an attacker writes there leave no trace after the container is restarted. Combined with the non-root user, this stops the most common post-exploitation persistence techniques completely.
Security Fix 3 — Drop Linux Capabilities
Linux capabilities are fine-grained privileges that determine what a process can do beyond normal user operations. Even non-root containers inherit a default set that includes privileges most applications will never use — and that attackers would love to exploit. The correct posture: drop everything, then add back only what's proven necessary.
docker run -d \
--name payment-api \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges \
-p 3000:3000 \
payment-api:v1.2.0
# --cap-drop ALL → strip every Linux capability from the container
# --cap-add NET_BIND_SERVICE → restore only the ability to bind ports below 1024
# only needed if your app directly binds port 80/443
# --security-opt no-new-privileges → blocks escalation through setuid binaries
Common Linux capabilities — what they allow
SYS_ADMIN
Mount filesystems, configure namespaces, set hostname. Effectively root on the host. Drop immediately.
NET_ADMIN
Configure network interfaces, routing, firewalls. Only needed for network tools — drop for all web services.
SYS_PTRACE
Trace and debug other processes. An attacker with this can read memory of every process on the system.
NET_BIND_SERVICE
Bind to privileged ports below 1024. Add only if the app serves directly on port 80 or 443.
Security Fix 4 — Scan Images for Vulnerabilities
Every layer in your image is built on software that contains bugs. Some of those bugs are security vulnerabilities with published CVEs. The node:18-alpine image you pulled last month may have critical vulnerabilities discovered and published last week. Scanning tells you before an attacker does.
# Docker Scout — built into Docker Desktop and Docker CLI
docker scout cves payment-api:v1.2.0
# Trivy — open-source, the standard in CI pipelines
trivy image payment-api:v1.2.0
# Run Trivy as a container — no local install needed
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image payment-api:v1.2.0
# In CI — fail the build on critical CVEs
trivy image --exit-code 1 --severity CRITICAL payment-api:v1.2.0
# Non-zero exit code fails the CI step → image never reaches the registry
payment-api:v1.2.0 (alpine 3.18.4) Total: 3 (CRITICAL: 1, HIGH: 2) Library Vulnerability Severity Installed Fixed openssl CVE-2023-5363 CRITICAL 3.1.3-r0 3.1.4-r0 libcrypto CVE-2023-4807 HIGH 3.1.3-r0 3.1.4-r0 libssl CVE-2023-3817 HIGH 3.1.3-r0 3.1.4-r0 Fix: Update base image → node:18-alpine3.19 (includes openssl 3.1.4-r0)
What just happened?
Three vulnerabilities found — one critical, two high — all in OpenSSL, which was shipped with the Alpine base image. The application code is completely clean. The CVEs were published weeks after the image was last rebuilt. The fix: one line change in the Dockerfile, one rebuild. Five minutes. Without a scanner in CI, this image would have shipped to production and sat there undetected, fully exploitable by anyone who read the CVE advisory.
Security Fix 5 — Never Mount the Docker Socket
Mounting the Docker socket (/var/run/docker.sock) into a container gives that container complete control over the Docker Daemon — it can start containers, pull any image, and mount any host directory. It is full root access to the host, dressed up as a container operation.
The Docker Socket Is a Root Backdoor
A container with socket access can run docker run -v /:/host alpine chroot /host — mounting the entire host filesystem and dropping into a root shell on the host machine. Any vulnerability in a socket-mounted container is a full host compromise. Use Kaniko or Buildah for CI image builds — they build OCI images without requiring the Docker Daemon, so the socket never needs to be exposed.
# NEVER do this in production:
docker run -v /var/run/docker.sock:/var/run/docker.sock some-ci-tool
# The host escape from inside the compromised container:
docker run -it --rm -v /:/host alpine chroot /host
# → root shell on the HOST operating system
# Safe alternative — Kaniko builds images without the Daemon:
docker run \
-v /workspace:/workspace \
gcr.io/kaniko-project/executor:latest \
--context=/workspace \
--destination=acmecorp/payment-api:latest
# No socket needed — builds and pushes entirely within the container
The Security Checklist
Before any container goes to production
--read-only + --tmpfs for writable paths--cap-drop ALL, add back only what's needed/var/run/docker.sockinternal: true keeps databases and caches off the internetTeacher's Note
Start with the non-root user and the vulnerability scan in CI. Those two alone put you ahead of the vast majority of Docker deployments running in production right now. Add the rest progressively.
Practice Questions
1. To mount a container's root filesystem as read-only — preventing any process inside from writing files to it — which docker run flag do you use?
2. To strip every Linux capability from a container — so only what is explicitly added back is permitted — which flag do you use?
3. Mounting this specific file into a container gives it complete control over the Docker Daemon — effectively root access to the host. What is the file path?
Quiz
1. A CI tool mounts /var/run/docker.sock into a container and an attacker exploits a vulnerability in it. What is the worst-case outcome?
2. A container runs with --read-only but crashes because it needs to write temp files to /tmp. The correct fix is:
3. A vulnerability scan on a three-week-old image reports a CRITICAL CVE in OpenSSL. The application code hasn't changed. Cause and fix:
Up Next · Lesson 33
Secrets Management
Security basics covered — now the harder problem: how do you get database passwords, API keys, and TLS certificates into containers securely at scale, without ever touching a .env file on a production server?