Security & Performance
Docker Security Best Practices
Docker Security Best Practices
Containers are not security boundaries by themselves. Without proper configuration, Docker containers can expose your application to serious vulnerabilities.
Container Isolation
Containers share the host kernel, so kernel exploits can break isolation:
<!-- Basic container runs as root by default (INSECURE) -->
docker run ubuntu:22.04 whoami
# Output: root
<!-- Use user namespaces for isolation -->
# /etc/docker/daemon.json
{
"userns-remap": "default"
}
<!-- Restart Docker to apply -->
sudo systemctl restart docker
<!-- Drop unnecessary capabilities -->
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx
<!-- Use seccomp profiles -->
docker run --security-opt seccomp=/path/to/seccomp-profile.json myapp
<!-- Enable AppArmor or SELinux -->
docker run --security-opt apparmor=docker-default myapp
docker run ubuntu:22.04 whoami
# Output: root
<!-- Use user namespaces for isolation -->
# /etc/docker/daemon.json
{
"userns-remap": "default"
}
<!-- Restart Docker to apply -->
sudo systemctl restart docker
<!-- Drop unnecessary capabilities -->
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx
<!-- Use seccomp profiles -->
docker run --security-opt seccomp=/path/to/seccomp-profile.json myapp
<!-- Enable AppArmor or SELinux -->
docker run --security-opt apparmor=docker-default myapp
Warning: Never run containers with --privileged flag in production. This disables all security features and gives the container full access to the host system.
Minimal Base Images
Use minimal base images to reduce attack surface:
<!-- BAD: Full OS image (1.13 GB) -->
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y nodejs npm
COPY . /app
CMD ["node", "server.js"]
<!-- BETTER: Official Node image (910 MB) -->
FROM node:18
COPY . /app
WORKDIR /app
RUN npm install
CMD ["node", "server.js"]
<!-- BEST: Alpine-based image (172 MB) -->
FROM node:18-alpine
COPY . /app
WORKDIR /app
RUN npm install --production
CMD ["node", "server.js"]
<!-- ULTIMATE: Distroless image (50 MB) -->
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
FROM gcr.io/distroless/nodejs18-debian11
COPY --from=builder /app /app
WORKDIR /app
CMD ["server.js"]
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y nodejs npm
COPY . /app
CMD ["node", "server.js"]
<!-- BETTER: Official Node image (910 MB) -->
FROM node:18
COPY . /app
WORKDIR /app
RUN npm install
CMD ["node", "server.js"]
<!-- BEST: Alpine-based image (172 MB) -->
FROM node:18-alpine
COPY . /app
WORKDIR /app
RUN npm install --production
CMD ["node", "server.js"]
<!-- ULTIMATE: Distroless image (50 MB) -->
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
FROM gcr.io/distroless/nodejs18-debian11
COPY --from=builder /app /app
WORKDIR /app
CMD ["server.js"]
Image Size Matters: Smaller images = fewer packages = fewer vulnerabilities = faster deployments. Alpine and distroless images often have 90% fewer CVEs than full OS images.
Non-Root Users
Always run containers as non-root users:
<!-- Create and use non-root user in Dockerfile -->
FROM node:18-alpine
# Create app user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Set ownership
COPY --chown=nodejs:nodejs . /app
WORKDIR /app
RUN npm install --production
# Switch to non-root user
USER nodejs
EXPOSE 3000
CMD ["node", "server.js"]
<!-- Verify user at runtime -->
docker run myapp whoami
# Output: nodejs (not root!)
<!-- Enforce non-root in Kubernetes -->
apiVersion: v1
kind: Pod
metadata:
name: myapp
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
containers:
- name: app
image: myapp:latest
FROM node:18-alpine
# Create app user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Set ownership
COPY --chown=nodejs:nodejs . /app
WORKDIR /app
RUN npm install --production
# Switch to non-root user
USER nodejs
EXPOSE 3000
CMD ["node", "server.js"]
<!-- Verify user at runtime -->
docker run myapp whoami
# Output: nodejs (not root!)
<!-- Enforce non-root in Kubernetes -->
apiVersion: v1
kind: Pod
metadata:
name: myapp
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
containers:
- name: app
image: myapp:latest
Docker Secrets Management
Never hardcode secrets in images. Use Docker secrets or environment variables:
<!-- BAD: Secrets in Dockerfile -->
FROM node:18
ENV DATABASE_PASSWORD="MySecretPass123"
ENV API_KEY="sk_live_abc123"
<!-- GOOD: Use Docker secrets (Swarm/Kubernetes) -->
# Create secret
echo "MySecretPass123" | docker secret create db_password -
# Use in service
docker service create \
--name myapp \
--secret db_password \
myapp:latest
# Access in application
const fs = require('fs');
const dbPassword = fs.readFileSync('/run/secrets/db_password', 'utf8');
<!-- Alternative: Environment variables -->
docker run -e DATABASE_PASSWORD="$DB_PASS" myapp
<!-- Kubernetes secrets -->
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
data:
db-password: TXlTZWNyZXRQYXNzMTIz # base64 encoded
---
apiVersion: v1
kind: Pod
spec:
containers:
- name: app
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: app-secrets
key: db-password
FROM node:18
ENV DATABASE_PASSWORD="MySecretPass123"
ENV API_KEY="sk_live_abc123"
<!-- GOOD: Use Docker secrets (Swarm/Kubernetes) -->
# Create secret
echo "MySecretPass123" | docker secret create db_password -
# Use in service
docker service create \
--name myapp \
--secret db_password \
myapp:latest
# Access in application
const fs = require('fs');
const dbPassword = fs.readFileSync('/run/secrets/db_password', 'utf8');
<!-- Alternative: Environment variables -->
docker run -e DATABASE_PASSWORD="$DB_PASS" myapp
<!-- Kubernetes secrets -->
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
data:
db-password: TXlTZWNyZXRQYXNzMTIz # base64 encoded
---
apiVersion: v1
kind: Pod
spec:
containers:
- name: app
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: app-secrets
key: db-password
Secret Management: Docker secrets are stored encrypted in Swarm. In Kubernetes, use sealed-secrets or external secret managers (AWS Secrets Manager, HashiCorp Vault) for production.
Image Scanning
Scan images for vulnerabilities before deployment:
<!-- Trivy - comprehensive vulnerability scanner -->
# Install Trivy
brew install aquasecurity/trivy/trivy
# Scan image
trivy image nginx:latest
# Scan with severity filter
trivy image --severity HIGH,CRITICAL myapp:latest
# Fail CI build on critical vulnerabilities
trivy image --exit-code 1 --severity CRITICAL myapp:latest
<!-- Docker Scout (built-in Docker Desktop) -->
docker scout cves myapp:latest
docker scout recommendations myapp:latest
<!-- Grype - fast vulnerability scanner -->
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh
./grype myapp:latest
<!-- Scan in Dockerfile build -->
FROM node:18-alpine AS scanner
RUN apk add --no-cache curl
RUN curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh
COPY --from=build /app /scan
RUN trivy fs --severity HIGH,CRITICAL --exit-code 1 /scan
# Install Trivy
brew install aquasecurity/trivy/trivy
# Scan image
trivy image nginx:latest
# Scan with severity filter
trivy image --severity HIGH,CRITICAL myapp:latest
# Fail CI build on critical vulnerabilities
trivy image --exit-code 1 --severity CRITICAL myapp:latest
<!-- Docker Scout (built-in Docker Desktop) -->
docker scout cves myapp:latest
docker scout recommendations myapp:latest
<!-- Grype - fast vulnerability scanner -->
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh
./grype myapp:latest
<!-- Scan in Dockerfile build -->
FROM node:18-alpine AS scanner
RUN apk add --no-cache curl
RUN curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh
COPY --from=build /app /scan
RUN trivy fs --severity HIGH,CRITICAL --exit-code 1 /scan
Runtime Security
Monitor and protect containers at runtime:
<!-- Read-only root filesystem -->
docker run --read-only --tmpfs /tmp myapp
<!-- In Dockerfile -->
FROM node:18-alpine
COPY . /app
WORKDIR /app
RUN npm install
USER nodejs
CMD ["node", "server.js"]
<!-- In docker-compose.yml -->
version: '3.8'
services:
app:
image: myapp:latest
read_only: true
tmpfs:
- /tmp
- /app/logs
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
<!-- Kubernetes SecurityContext -->
apiVersion: v1
kind: Pod
spec:
containers:
- name: app
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
docker run --read-only --tmpfs /tmp myapp
<!-- In Dockerfile -->
FROM node:18-alpine
COPY . /app
WORKDIR /app
RUN npm install
USER nodejs
CMD ["node", "server.js"]
<!-- In docker-compose.yml -->
version: '3.8'
services:
app:
image: myapp:latest
read_only: true
tmpfs:
- /tmp
- /app/logs
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
<!-- Kubernetes SecurityContext -->
apiVersion: v1
kind: Pod
spec:
containers:
- name: app
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
Critical: Read-only filesystems prevent attackers from modifying binaries or installing malware. Always use tmpfs for directories that need write access.
Docker Compose Security
Secure multi-container applications:
<!-- docker-compose.yml with security best practices -->
version: '3.8'
services:
web:
image: myapp:latest
build:
context: .
dockerfile: Dockerfile
user: "1001:1001"
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
environment:
- DB_HOST=postgres
- DB_PASSWORD_FILE=/run/secrets/db_password
secrets:
- db_password
networks:
- frontend
- backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 3s
retries: 3
postgres:
image: postgres:15-alpine
user: postgres
read_only: true
tmpfs:
- /tmp
- /var/run/postgresql
volumes:
- postgres_data:/var/lib/postgresql/data:rw
environment:
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
secrets:
- db_password
networks:
- backend
secrets:
db_password:
file: ./secrets/db_password.txt
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # No external access
volumes:
postgres_data:
driver: local
version: '3.8'
services:
web:
image: myapp:latest
build:
context: .
dockerfile: Dockerfile
user: "1001:1001"
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
environment:
- DB_HOST=postgres
- DB_PASSWORD_FILE=/run/secrets/db_password
secrets:
- db_password
networks:
- frontend
- backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 3s
retries: 3
postgres:
image: postgres:15-alpine
user: postgres
read_only: true
tmpfs:
- /tmp
- /var/run/postgresql
volumes:
- postgres_data:/var/lib/postgresql/data:rw
environment:
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
secrets:
- db_password
networks:
- backend
secrets:
db_password:
file: ./secrets/db_password.txt
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # No external access
volumes:
postgres_data:
driver: local
Security Scanning in CI/CD
Automate image scanning in your pipeline:
<!-- GitHub Actions workflow -->
# .github/workflows/docker-security.yml
name: Docker Security Scan
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Run Trivy scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: 'trivy-results.sarif'
- name: Run Hadolint (Dockerfile linter)
uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: Dockerfile
failure-threshold: warning
- name: Docker Scout CVE scan
uses: docker/scout-action@v1
with:
command: cves
image: myapp:${{ github.sha }}
only-severities: critical,high
exit-code: true
# .github/workflows/docker-security.yml
name: Docker Security Scan
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Run Trivy scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: 'trivy-results.sarif'
- name: Run Hadolint (Dockerfile linter)
uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: Dockerfile
failure-threshold: warning
- name: Docker Scout CVE scan
uses: docker/scout-action@v1
with:
command: cves
image: myapp:${{ github.sha }}
only-severities: critical,high
exit-code: true
Multi-Layer Defense: Use Hadolint to catch Dockerfile misconfigurations, Trivy/Scout for vulnerability scanning, and runtime security tools like Falco for live monitoring.
Docker Security Checklist
<!-- Complete Docker security checklist -->
✓ Image Security:
- Use minimal base images (Alpine, distroless)
- Scan images with Trivy/Grype
- Sign images with Docker Content Trust
- Use specific tags, not :latest
- Multi-stage builds to exclude build tools
✓ Runtime Security:
- Run as non-root user
- Read-only root filesystem
- Drop all capabilities, add only needed
- Use seccomp/AppArmor profiles
- Enable user namespaces
✓ Secrets Management:
- Never hardcode secrets in images
- Use Docker secrets or secret managers
- Scan for exposed secrets with git-secrets
- Rotate secrets regularly
✓ Network Security:
- Use custom bridge networks
- Isolate backend services (internal: true)
- Implement network policies
- Use TLS for inter-service communication
✓ Monitoring:
- Enable Docker audit logging
- Monitor resource usage (CPU, memory)
- Use runtime security tools (Falco, Sysdig)
- Regular security audits
✓ Image Security:
- Use minimal base images (Alpine, distroless)
- Scan images with Trivy/Grype
- Sign images with Docker Content Trust
- Use specific tags, not :latest
- Multi-stage builds to exclude build tools
✓ Runtime Security:
- Run as non-root user
- Read-only root filesystem
- Drop all capabilities, add only needed
- Use seccomp/AppArmor profiles
- Enable user namespaces
✓ Secrets Management:
- Never hardcode secrets in images
- Use Docker secrets or secret managers
- Scan for exposed secrets with git-secrets
- Rotate secrets regularly
✓ Network Security:
- Use custom bridge networks
- Isolate backend services (internal: true)
- Implement network policies
- Use TLS for inter-service communication
✓ Monitoring:
- Enable Docker audit logging
- Monitor resource usage (CPU, memory)
- Use runtime security tools (Falco, Sysdig)
- Regular security audits
Exercise: Secure an existing Dockerfile: (1) Switch to Alpine or distroless base image, (2) Add non-root user, (3) Enable read-only filesystem, (4) Scan with Trivy and fix HIGH/CRITICAL vulnerabilities. Compare image sizes and vulnerability counts before/after.