Docker Security Best Practices
이 글의 핵심
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.
Frequently Asked Questions (FAQ)
Q. When would I use this in practice?
A. Secure Docker containers in production. Covers non-root users, read-only filesystems, image scanning, secrets management…
Q. What should I read before this?
A. Follow the previous article or related articles links at the bottom of each post to learn in sequence.
Q. Where can I study this more deeply?
A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.
Related Articles (Internal Links)
Other articles related to this topic.
Keywords Covered in This Article (Related Search Terms)
This article covers Docker, Security, DevOps, Container, Kubernetes, DevSecOps.