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:
- Vulnerable base images — outdated packages with known CVEs
- Privileged containers — unnecessary capabilities, running as root
- Secrets in images — API keys baked into layers
- Container escape — privileged containers can break out to the host
- 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
Trivy (free, recommended)
# 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 neededNET_ADMIN— network configSYS_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
| Layer | Security measure |
|---|---|
| Image build | Non-root user, minimal base, multi-stage, no secrets |
| Image scanning | Trivy in CI, fail on CRITICAL |
| Secrets | External secrets manager, never in image |
| Runtime | Read-only FS, drop capabilities, resource limits |
| Network | Internal Docker networks, no direct DB exposure |
| Kubernetes | securityContext, 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.