DevSecOps

Container Security Best Practices: Docker and Kubernetes

Containers introduce new attack surfaces. Learn Docker security essentials: minimal images, non-root users, read-only filesystems, image scanning, secrets management, and runtime protection.

August 26, 20256 min readShipSafer Team

Containers have become the default deployment unit for modern applications, but they come with their own security considerations. A misconfigured container can give attackers root access on the host — and most default Docker configurations are insecure.

This guide covers the essential security practices for containerized workloads.

The Container Security Threat Model

Containers share the host kernel. Unlike VMs (which have a hypervisor between them), a compromised container can escape to the host if:

  • The container runs as root
  • Dangerous capabilities are granted
  • The container has access to host resources (socket, namespaces, /proc)

The attacker's path: exploit app vulnerability → code execution in container → container escape → root on host → lateral movement.

1. Use Minimal Base Images

The fewer packages in an image, the smaller the attack surface.

# ❌ Heavyweight — 900MB, hundreds of packages
FROM ubuntu:22.04

# ✅ Better — 30MB, minimal packages
FROM debian:bookworm-slim

# ✅ Best for production — no shell, no package manager
FROM gcr.io/distroless/nodejs22-debian12

Distroless images (Google's distroless project, Chainguard) contain only your application and its runtime dependencies — no shell, no package manager, no utilities. This dramatically limits what an attacker can do even if they get code execution.

# Multi-stage build with distroless final image
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

FROM gcr.io/distroless/nodejs22-debian12 AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER nonroot
CMD ["dist/server.js"]

2. Run as Non-Root User

Docker containers run as root by default. If a container is compromised, the attacker has root inside the container — and if container escape is possible, root on the host.

# Create a non-root user
FROM node:22-alpine
WORKDIR /app

# Add a non-root user
RUN addgroup -g 1001 -S appgroup && \
    adduser -S appuser -u 1001 -G appgroup

COPY --chown=appuser:appgroup . .
RUN npm ci --only=production

# Switch to non-root
USER appuser

EXPOSE 3000
CMD ["node", "server.js"]

In Kubernetes, also set the security context:

spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 1001
    runAsGroup: 1001
    fsGroup: 1001
  containers:
  - name: app
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop: ["ALL"]

3. Read-Only Root Filesystem

Prevent attackers from modifying the container filesystem:

# In docker run:
docker run --read-only myimage

# But allow /tmp for temporary files
docker run --read-only --tmpfs /tmp myimage

In Kubernetes:

securityContext:
  readOnlyRootFilesystem: true

If your app writes to disk, use explicit volume mounts rather than the root filesystem. This limits what an attacker can modify even with code execution.

4. Drop All Capabilities

Linux capabilities divide root privileges into granular units. Docker grants a large set by default. Drop all and add back only what's needed:

# Drop all capabilities
docker run --cap-drop ALL myimage

# Add back only what's needed (e.g., bind to port 80)
docker run --cap-drop ALL --cap-add NET_BIND_SERVICE myimage

Most web applications need zero capabilities. If you're running as a non-root user on a port > 1024, you need no capabilities at all.

# Kubernetes
securityContext:
  capabilities:
    drop: ["ALL"]
    # add: ["NET_BIND_SERVICE"]  # Only if binding port <1024

5. Secrets Management

Never bake secrets into images. They persist in image layers and can be extracted:

# ❌ Secret is in the image layer forever
RUN curl -H "Authorization: Bearer sk_live_abc123" https://api.example.com/setup

# ❌ ARG values are visible in docker history
ARG API_KEY
RUN ./setup.sh --key=$API_KEY

Correct approaches:

Runtime environment variables (from a secrets manager):

docker run -e API_KEY=$(aws secretsmanager get-secret-value --secret-id mykey --query SecretString --output text) myimage

Kubernetes Secrets (use External Secrets Operator to sync from AWS Secrets Manager, Vault, etc.):

apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
stringData:
  API_KEY: "injected-by-operator"
---
spec:
  containers:
  - name: app
    envFrom:
    - secretRef:
        name: app-secrets

Docker BuildKit secrets (for build-time secrets that don't land in layers):

# syntax=docker/dockerfile:1
FROM node:22-alpine
RUN --mount=type=secret,id=npm_token npm config set //registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)

6. Image Vulnerability Scanning

Scan images for known CVEs before deploying:

# Docker Scout (built into Docker Desktop)
docker scout cves myimage:latest

# Trivy (open source, fast)
trivy image myimage:latest
trivy image --severity CRITICAL,HIGH myimage:latest

# In CI/CD with exit code:
trivy image --exit-code 1 --severity CRITICAL myimage:latest
# Fails the build if critical CVEs found

Integrate scanning into your CI/CD pipeline so every push to main is scanned:

# GitHub Actions
- name: Scan image with Trivy
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}'
    format: 'sarif'
    exit-code: '1'
    severity: 'CRITICAL,HIGH'

7. Docker Daemon Security

Secure the Docker daemon itself:

# Never expose Docker socket to containers (enables full host escape)
# ❌ This gives root on the host
docker run -v /var/run/docker.sock:/var/run/docker.sock myimage

# Enable Docker Content Trust (image signing)
export DOCKER_CONTENT_TRUST=1
docker pull myimage  # Verifies signature

# Use rootless Docker mode (daemon doesn't run as root)
# Install: https://docs.docker.com/engine/security/rootless/

8. Dockerfile Best Practices Summary

# 1. Pin specific versions (not :latest)
FROM node:22.3.0-alpine3.20

# 2. Multi-stage builds (minimize final image size)
FROM node:22-alpine AS builder
# ... build steps ...

FROM gcr.io/distroless/nodejs22-debian12 AS runtime
# Copy only what's needed

# 3. COPY specific files, not entire context
COPY package*.json ./
RUN npm ci --only=production
COPY src/ ./src/

# 4. Non-root user
USER nonroot:nonroot

# 5. No SUID/SGID bits
RUN find / -perm /6000 -type f -exec chmod a-s {} \; 2>/dev/null || true

# 6. Explicit port (documentation + tooling)
EXPOSE 3000

# 7. HEALTHCHECK
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3000/health || exit 1

Checking Your Configuration

Run Docker Bench for Security against your Docker installation:

docker run --net host --pid host --userns host --cap-add audit_control \
  -e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
  -v /etc:/etc:ro \
  -v /usr/bin/containerd:/usr/bin/containerd:ro \
  -v /usr/bin/runc:/usr/bin/runc:ro \
  -v /usr/lib/systemd:/usr/lib/systemd:ro \
  -v /var/lib:/var/lib:ro \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  --label docker_bench_security \
  docker/docker-bench-security

This runs CIS Docker Benchmark checks and gives you a scored report of your configuration.

docker
containers
kubernetes
devsecops
infrastructure-security

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.