Docker Security Best Practices | Hardening Containers for Production

Docker Security Best Practices | Hardening Containers for Production

이 글의 핵심

Running containers in production without security hardening is like deploying a web app without HTTPS. This guide covers the essential Docker security practices — from Dockerfile hardening to runtime protection.

The Container Security Threat Model

Threats to containerized applications:

  1. Vulnerable base images — outdated packages with known CVEs
  2. Privileged containers — unnecessary capabilities, running as root
  3. Secrets in images — API keys baked into layers
  4. Container escape — privileged containers can break out to the host
  5. Supply chain — malicious base images or packages

Defense-in-depth: secure each layer independently.


1. Dockerfile Security

Non-root user

# ❌ Runs as root by default
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

# ✅ Create and use a non-root user
FROM node:20-alpine

# Create app user
RUN addgroup -g 1001 -S appgroup && \
    adduser -u 1001 -S appuser -G appgroup

WORKDIR /app
COPY --chown=appuser:appgroup package*.json ./
RUN npm ci --only=production

COPY --chown=appuser:appgroup . .

# Switch to non-root user
USER appuser

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

Minimal base images

# ❌ Full Debian — hundreds of packages, large attack surface
FROM node:20

# ✅ Alpine — minimal, ~5MB
FROM node:20-alpine

# ✅ Distroless — no shell, no package manager
FROM gcr.io/distroless/nodejs20-debian12

Distroless images have no shell — attackers can’t run arbitrary commands even if they breach the container.

Multi-stage builds (no build tools in production)

# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production stage — only runtime artifacts
FROM node:20-alpine AS production
RUN addgroup -g 1001 -S appgroup && adduser -u 1001 -S appuser -G appgroup
WORKDIR /app

COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/package.json .

USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]

Never include: npm, apt, build tools, source code, .git, test files, .env.

Pin base image versions

# ❌ Latest tag can break unexpectedly and includes unknown changes
FROM node:latest
FROM node:20-alpine

# ✅ Pin to specific digest for reproducibility
FROM node:20.12.0-alpine3.19@sha256:abc123...

# ✅ Or at minimum pin the minor version
FROM node:20.12-alpine3.19

2. Image Scanning

# Install
brew install trivy  # macOS
# or: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh

# Scan image
trivy image node:20-alpine

# Scan with severity filter
trivy image --severity HIGH,CRITICAL myapp:latest

# Scan and fail CI if CRITICAL found
trivy image --exit-code 1 --severity CRITICAL myapp:latest

# Scan Dockerfile for misconfigurations
trivy config ./Dockerfile

# Scan filesystem
trivy fs --security-checks vuln,config .

CI Integration (GitHub Actions)

# .github/workflows/security.yml
- name: Scan Docker image
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: 'myapp:${{ github.sha }}'
    format: 'table'
    exit-code: '1'
    severity: 'CRITICAL,HIGH'

3. Secrets Management

# ❌ Never bake secrets into images
ENV DATABASE_URL=postgres://prod-db/myapp
ENV API_KEY=sk-production-key-123

# ❌ Never COPY .env files
COPY .env .

# ✅ Pass at runtime via environment variables
# docker run -e DATABASE_URL=$DATABASE_URL myapp

# ✅ Use Docker build secrets (don't leak in image layers)
# syntax=docker/dockerfile:1
FROM node:20-alpine
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm install

# Build with:
# docker build --secret id=npm_token,env=NPM_TOKEN .

Docker Compose secrets

# docker-compose.yml
services:
  app:
    image: myapp
    secrets:
      - db_password
      - api_key
    environment:
      - DB_PASSWORD_FILE=/run/secrets/db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt
  api_key:
    external: true  # from Docker Swarm secrets

External secrets manager (production)

# AWS Secrets Manager
aws secretsmanager get-secret-value --secret-id myapp/prod/db \
  --query SecretString --output text

# HashiCorp Vault
vault kv get -field=password secret/myapp/database

Never store production secrets in environment variables for highly sensitive data — use a secrets manager with rotation.


4. Runtime Security

Read-only filesystem

# docker-compose.yml
services:
  app:
    image: myapp
    read_only: true  # root filesystem is read-only
    tmpfs:
      - /tmp         # writable temp directory
      - /var/run     # writable for PID files
    volumes:
      - app-data:/data  # named volume for persistent data

Drop capabilities

services:
  app:
    image: myapp
    cap_drop:
      - ALL              # drop all Linux capabilities
    cap_add:
      - NET_BIND_SERVICE # only add back what's needed (port < 1024)
    security_opt:
      - no-new-privileges:true  # prevent privilege escalation

Linux capabilities to drop:

  • SYS_ADMIN — most dangerous, almost never needed
  • NET_ADMIN — network config
  • SYS_PTRACE — process debugging

Resource limits

services:
  app:
    image: myapp
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M
        reservations:
          cpus: '0.5'
          memory: 256M
    # Prevent fork bombs
    ulimits:
      nproc: 65535
      nofile:
        soft: 1024
        hard: 1024

5. Network Security

# Isolate services with networks
services:
  app:
    networks:
      - frontend  # exposed to public
      - backend   # internal only

  db:
    networks:
      - backend   # no public access

  nginx:
    ports:
      - "80:80"
      - "443:443"
    networks:
      - frontend

networks:
  frontend:
  backend:
    internal: true  # no external access

6. Kubernetes Security Context

apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 1001
        runAsGroup: 1001
        fsGroup: 1001
        seccompProfile:
          type: RuntimeDefault

      containers:
        - name: app
          image: myapp:1.0.0
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]

          resources:
            limits:
              cpu: "1"
              memory: "512Mi"
            requests:
              cpu: "100m"
              memory: "128Mi"

          volumeMounts:
            - name: tmp
              mountPath: /tmp

      volumes:
        - name: tmp
          emptyDir: {}

7. .dockerignore

# ❌ Without .dockerignore, everything is included in build context

# ✅ .dockerignore
.git
.gitignore
node_modules
*.log
.env
.env.*
dist
coverage
.nyc_output
*.test.ts
*.spec.ts
Dockerfile
docker-compose*.yml
README.md
.github
secrets/

Security Checklist

□ Base image: alpine or distroless, pinned version
□ Non-root USER in Dockerfile
□ Multi-stage build (no build tools in production image)
□ No secrets in image (no .env, no hardcoded keys)
□ Trivy scan in CI pipeline
□ Read-only root filesystem
□ All capabilities dropped (cap_drop: ALL)
□ no-new-privileges: true
□ Resource limits (CPU and memory)
□ Network isolation (internal networks for databases)
□ .dockerignore excludes .git, .env, test files

Key Takeaways

LayerSecurity measure
Image buildNon-root user, minimal base, multi-stage, no secrets
Image scanningTrivy in CI, fail on CRITICAL
SecretsExternal secrets manager, never in image
RuntimeRead-only FS, drop capabilities, resource limits
NetworkInternal Docker networks, no direct DB exposure
KubernetessecurityContext, readOnlyRootFilesystem, runAsNonRoot

Container security is not optional in production — a compromised container with root access and no capability restrictions can escape to the host. Implement these practices at project start; retrofitting them is harder than getting them right initially.