Learn how to secure Docker containers from image hardening and vulnerability scanning to runtime protection - covering non-root users, minimal base images, multi-stage builds, Trivy scanning, seccomp/AppArmor profiles, capability dropping, read-only filesystems, and the Docker socket threat.
A container feels like an isolated environment. You run `docker run`, and your application appears to run alone. But containers are not virtual machines — they share the host kernel. Every process inside every container is running directly on the same Linux kernel as every other container and the host itself. This sharing is what makes containers fast and lightweight. It is also what makes container security different from VM security. When a vulnerability allows an attacker to escape a VM, they need to exploit the hypervisor — a small, well-audited piece of software. When an attacker escapes a container, they are already in the same kernel space as everything else on the host. One container compromise can become a full host compromise. The statistics confirm the risk: a 2024 analysis of Docker Hub found that 76% of publicly available images contain known security vulnerabilities. Most teams pull these images and deploy them without scanning. The image that looks clean may have been built six months ago with packages that have since received critical CVE patches. Container security has four layers: ``` Layer 1: Build security What goes into your image? Are you using a minimal base? Non-root user? No secrets in layers? Layer 2: Image scanning What vulnerabilities exist in your image? Trivy, Grype, Docker Scout — run before every push Layer 3: Runtime hardening How does your container behave at runtime? Capabilities dropped? Seccomp profile? Read-only filesystem? Layer 4: Registry and deployment security Who can push to your registry? Are images signed? Is the Docker socket protected? ``` This module covers all four layers. ---
The Dockerfile is where container security starts. Every decision you make here determines the attack surface of every container that image ever runs. ### Use Minimal Base Images The more packages in a base image, the more potential vulnerabilities. A full Ubuntu image comes with hundreds of packages — cron, SSH, compilers, debugging tools — that your application almost certainly never uses. Each is a potential vulnerability vector. ```dockerfile # Bad — 880MB image, hundreds of packages, many CVEs FROM ubuntu:latest RUN apt-get install -y python3 python3-pip COPY requirements.txt . RUN pip install -r requirements.txt COPY . . CMD ["python3", "app.py"] # Better — Alpine, ~5MB base, minimal packages FROM python:3.11-alpine RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["python3", "app.py"] # Best — Distroless, no shell, no package manager, minimal CVEs FROM python:3.11-slim AS builder WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --target /app/packages -r requirements.txt COPY . . FROM gcr.io/distroless/python3 COPY --from=builder /app /app WORKDIR /app CMD ["app.py"] ``` Distroless images from Google contain only the application runtime and its dependencies. No shell, no package manager, no cron, no utilities. If an attacker gains code execution inside a distroless container, they cannot spawn a shell to explore the system. The attack surface is as small as it can be. A comparison of real-world size and CVE counts: | Base Image | Size | Typical CVEs | |:-----------|:-----|:-------------| | ubuntu:latest | 80MB | 100+ | | python:3.11 | 900MB | 100+ | | python:3.11-slim | 130MB | 30-50 | | python:3.11-alpine | 50MB | 5-15 | | distroless/python3 | 50MB | 0-5 | ### Multi-Stage Builds — Never Ship Build Tools Multi-stage builds solve a critical problem: the tools you need to compile and build your application should never be in your production image. ```dockerfile # Stage 1: Builder — has all build tools, compilers, dev dependencies FROM node:18 AS builder WORKDIR /app COPY package*.json ./ RUN npm ci # Installs ALL dependencies including devDependencies COPY . . RUN npm run build # Compiles TypeScript, bundles assets, etc. # Stage 2: Production — only the runtime, nothing to compile with FROM node:18-alpine AS production WORKDIR /app COPY package*.json ./ RUN npm ci --only=production # Only production dependencies COPY --from=builder /app/dist ./dist # Only compiled output # Create non-root user RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser EXPOSE 3000 CMD ["node", "dist/server.js"] ``` The production image has no source code, no TypeScript compiler, no test frameworks, no development dependencies. If an attacker compromises the running container, they cannot easily understand what the application does or modify it. ### Never Run as Root By default, most containers run as root. If an attacker gets code execution inside that container, they have root privileges — they can read any file, write to any directory, install malware, and in many configurations, escape to the host. Running as a non-root user is the single most impactful change you can make to container security: ```dockerfile FROM python:3.11-alpine # Create a non-root user and group RUN addgroup -S appgroup && adduser -S appuser -G appgroup WORKDIR /app # Install dependencies as root (before switching users) COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Copy application files, owned by non-root user COPY --chown=appuser:appgroup . . # Switch to non-root user USER appuser # Non-privileged port (applications must use ports > 1024) EXPOSE 8080 CMD ["python3", "app.py"] ``` For applications that need to listen on port 80 or 443 but run as non-root: configure a reverse proxy (nginx, Traefik) in front of your container, or use Kubernetes services to map port 80 to container port 8080. ### Pin Specific Versions — Never Use `latest` ```dockerfile # Dangerous — no guarantee of reproducibility, could introduce vulnerabilities FROM python:latest FROM node:latest # Safe — exact version, predictable, scannable FROM python:3.11.7-slim FROM node:18.19.1-alpine3.19 # Even better — pin to digest (immutable, cannot be changed by publisher) FROM python:3.11.7-slim@sha256:3d6a6c0e6e1b6e5e0a5b... ``` When you pin to a digest, you are guaranteed to always get exactly the same image — even if the publisher updates the tag. Your build is reproducible and auditable. ### Use .dockerignore ``` # .dockerignore — prevent sensitive files entering the build context .git .gitignore *.log .env .env.* secrets/ *.pem *.key node_modules/ __pycache__/ *.pyc .pytest_cache/ tests/ docs/ README.md Dockerfile .dockerignore ``` Files in the build context are sent to the Docker daemon. Anything not in .dockerignore could potentially end up in your image. A `.env` file with database credentials accidentally copied into a layer is a secret leakage waiting to be discovered. ### Use COPY, Not ADD ```dockerfile # ADD can fetch remote URLs and auto-extract archives — potential for abuse ADD http://example.com/file.tar.gz /tmp/ # Never do this # COPY is explicit and predictable COPY src/ /app/src/ COPY requirements.txt /app/ ``` ---
Trivy is the industry standard open-source scanner for container images. It detects CVEs in OS packages, application dependencies, misconfigurations in Dockerfiles, and exposed secrets. ### Install and Basic Scan ```bash # Install Trivy brew install trivy # macOS apt-get install trivy # Ubuntu/Debian # Scan a container image from Docker Hub trivy image nginx:latest # Scan only HIGH and CRITICAL vulnerabilities trivy image --severity HIGH,CRITICAL nginx:latest # Scan and ignore vulnerabilities with no available fix trivy image --ignore-unfixed --severity HIGH,CRITICAL nginx:latest # Fail CI pipeline if CRITICAL vulnerabilities found (exit code 1) trivy image --exit-code 1 --severity CRITICAL myapp:latest # Scan a locally built image docker build -t myapp:dev . trivy image myapp:dev ``` ### Understanding Trivy Output ``` myapp:dev (ubuntu 22.04) Total: 12 (HIGH: 8, CRITICAL: 4) ┌─────────────┬────────────────┬──────────┬───────────────────┬──────────────────┐ │ Library │ Vulnerability │ Severity │ Installed Version │ Fixed Version │ ├─────────────┼────────────────┼──────────┼───────────────────┼──────────────────┤ │ openssl │ CVE-2023-0286 │ CRITICAL │ 3.0.2-0ubuntu1.7 │ 3.0.2-0ubuntu1.8 │ │ curl │ CVE-2023-27534 │ HIGH │ 7.81.0-1ubuntu1.8 │ 7.81.0-1ubuntu1.9│ └─────────────┴────────────────┴──────────┴───────────────────┴──────────────────┘ ``` The key column is **Fixed Version** — if a fix exists, upgrade immediately. If it shows "N/A" or is blank, no patch is available yet, and you need to assess whether the vulnerability is actually reachable in your use case. ### Trivy in GitHub Actions ```yaml # .github/workflows/container-scan.yml name: Container Security Scan on: push: branches: [main] pull_request: branches: [main] jobs: trivy-scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Build container image run: docker build -t myapp:${{ github.sha }} . # Upload findings to GitHub Security tab - name: Run Trivy vulnerability scan uses: aquasecurity/trivy-action@master with: image-ref: myapp:${{ github.sha }} format: sarif output: trivy-results.sarif severity: CRITICAL,HIGH - name: Upload Trivy scan results to GitHub Security uses: github/codeql-action/upload-sarif@v3 if: always() with: sarif_file: trivy-results.sarif # Fail the build on CRITICAL findings - name: Fail on critical vulnerabilities uses: aquasecurity/trivy-action@master with: image-ref: myapp:${{ github.sha }} exit-code: 1 severity: CRITICAL ``` ### Scan Dockerfiles for Misconfigurations Trivy also checks Dockerfiles against security best practices: ```bash trivy config Dockerfile # Example output: # Dockerfile (dockerfile) # ======================= # MEDIUM: Last USER command in Dockerfile should not be 'root' # HIGH: Add HEALTHCHECK instruction to the container image # HIGH: Specify a tag in the 'FROM' statement for image ubuntu ``` ---
The Docker daemon socket `/var/run/docker.sock` is the most dangerous file on your host. Whoever controls this file controls Docker — which means they control every container, can mount any host directory, and can effectively become root on the host. ### Why the Socket Is Dangerous ```bash # Anyone with access to /var/run/docker.sock can: # 1. List all running containers (including their environment variables with secrets) docker -H unix:///var/run/docker.sock ps -a # 2. Start a privileged container that mounts the entire host filesystem docker -H unix:///var/run/docker.sock run -it \ --privileged \ -v /:/host \ alpine chroot /host /bin/bash # → Full root access to the host # 3. Read secrets from other containers docker -H unix:///var/run/docker.sock exec TARGET_CONTAINER cat /etc/secrets/db-password ``` ### Rules for the Docker Socket ``` Rule 1: Never mount /var/run/docker.sock into a container BAD: docker run -v /var/run/docker.sock:/var/run/docker.sock myapp BAD: volumes: ["/var/run/docker.sock:/var/run/docker.sock"] GOOD: Use proper orchestration APIs instead Rule 2: Never expose the Docker daemon over TCP without TLS BAD: dockerd -H tcp://0.0.0.0:2375 GOOD: dockerd -H unix:///var/run/docker.sock (default) GOOD: dockerd -H tcp://0.0.0.0:2376 (TLS only) Rule 3: Only trusted users in the docker group The docker group is equivalent to root access Audit group membership regularly: grep docker /etc/group Rule 4: Use rootless Docker for development dockerd-rootless-setuptool.sh install DOCKER_HOST=unix:///run/user/1000/docker.sock docker run ... ``` ---
Linux capabilities divide the power of root into discrete units. By default, Docker grants containers a subset of capabilities — more than they typically need. Explicitly dropping all capabilities and adding back only what is required is the principle of least privilege applied to containers. ### Default vs Secure Capability Configuration ```bash # Run with ALL capabilities dropped — most secure docker run \ --cap-drop ALL \ --cap-add CHOWN \ --cap-add NET_BIND_SERVICE \ my-webserver:latest # Never do this — grants every possible privilege docker run --privileged myapp # Check what capabilities a container actually needs docker run --cap-drop ALL myapp 2>&1 | grep "Operation not permitted" # Then add back only the specific capabilities that caused errors ``` ### In Docker Compose ```yaml services: web: image: nginx:alpine cap_drop: - ALL # Drop everything first cap_add: - CHOWN # Allow changing file ownership - NET_BIND_SERVICE # Allow binding to ports < 1024 - SETUID # Allow changing user ID (needed by nginx worker) security_opt: - no-new-privileges:true # Prevent privilege escalation via setuid binaries ``` ### Common Capabilities and When You Need Them | Capability | What it allows | Need it? | |:-----------|:---------------|:---------| | `CHOWN` | Change file ownership | Only if app changes file owners | | `NET_BIND_SERVICE` | Bind to ports < 1024 | Only if listening on port 80/443 | | `SYS_PTRACE` | Debug other processes | Never in production | | `NET_ADMIN` | Configure network interfaces | Only for network tools | | `SYS_ADMIN` | Many privileged operations | Almost never | | `ALL` (--privileged) | Everything | Never | ---
Even with capabilities dropped, a container can still make hundreds of system calls. Seccomp (Secure Computing Mode) restricts which system calls a process can make. If a syscall is blocked and the container tries to make it, the kernel kills the process. AppArmor restricts which files, network connections, and capabilities a process can access. ### Seccomp — Restricting System Calls Docker's default seccomp profile blocks about 44 dangerous syscalls. Using it explicitly: ```bash # Use Docker's default seccomp profile (explicitly) docker run \ --security-opt seccomp=/etc/docker/seccomp.json \ myapp # Verify seccomp is applied docker inspect myapp | grep -A 5 Seccomp ``` For custom profiles, create a JSON file listing allowed syscalls: ```json { "defaultAction": "SCMP_ACT_ERRNO", "architectures": ["SCMP_ARCH_X86_64"], "syscalls": [ { "names": [ "read", "write", "open", "close", "stat", "fstat", "lstat", "poll", "lseek", "mmap", "munmap", "brk", "rt_sigaction", "rt_sigprocmask", "ioctl", "socket", "connect", "accept", "sendto", "recvfrom", "sendmsg", "recvmsg", "shutdown", "bind", "listen", "getsockname", "getpeername", "socketpair", "setsockopt", "getsockopt", "clone", "fork", "execve", "exit", "wait4", "kill", "uname", "futex", "gettid", "getpid", "getppid", "getuid", "geteuid", "getgid", "getegid" ], "action": "SCMP_ACT_ALLOW" } ] } ``` Apply it: ```bash docker run --security-opt seccomp=custom-profile.json myapp ``` ### AppArmor — Mandatory Access Control AppArmor profiles define exactly which files a process can access and what it can do: ```bash # Check if AppArmor is running aa-status # Apply Docker's default AppArmor profile explicitly docker run --security-opt apparmor=docker-default myapp # Load a custom AppArmor profile apparmor_parser -r -W /etc/apparmor.d/my-app-profile docker run --security-opt apparmor=my-app-profile myapp ``` The key difference between seccomp and AppArmor: * **Seccomp** — restricts what system calls the process can make * **AppArmor** — restricts what resources (files, networks) the process can access Use both for defense in depth. ---
A container feels like an isolated environment. You run docker run, and your application appears to run alone. But conta...
The Dockerfile is where container security starts. Every decision you make here determines the attack surface of every c...
Trivy is the industry standard open-source scanner for container images. It detects CVEs in OS packages, application dep...
The Docker daemon socket /var/run/docker.sock is the most dangerous file on your host. Whoever controls this file contro...
Linux capabilities divide the power of root into discrete units. By default, Docker grants containers a subset of capabi...
Even with capabilities dropped, a container can still make hundreds of system calls. Seccomp (Secure Computing Mode) res...
A read-only root filesystem means that even if an attacker gains code execution inside your container, they cannot write...
Without resource limits, a compromised container can exhaust CPU, memory, or file descriptors — causing denial of servic...
A HEALTHCHECK instruction tells Docker how to determine if your container is healthy. Combined with --restart=on-failure...
Putting all best practices together: Run this with: ---...
This lab takes a deliberately insecure Dockerfile and progressively hardens it, scanning at each step with Trivy to meas...
Aligns directly with DevOps, Site Reliability (SRE), and Platform Engineering job descriptions.