Questions
This container is running as root with full capabilities. Secure it for production.
The Scenario
Your security team flagged this Dockerfile during a compliance audit:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]
Security findings:
- Container runs as root (UID 0)
- All Linux capabilities enabled
- Writable filesystem
- No resource limits
- Base image has known vulnerabilities
You need to harden this container for PCI-DSS compliance before the next deployment.
The Challenge
Implement security best practices to harden this container. Explain each security measure and the attack vectors it prevents.
A junior engineer might just add USER node to the Dockerfile thinking that's enough, not understand why capabilities matter, skip vulnerability scanning because the app works fine, or think these are just nice-to-haves that can wait. This fails because USER alone isn't sufficient without proper file ownership, capabilities can enable privilege escalation, vulnerabilities get exploited in production, and compliance failures block deployments.
A senior engineer implements defense in depth: run as non-root user with proper ownership, drop all unnecessary capabilities, make the filesystem read-only where possible, set resource limits, use minimal base images, scan for vulnerabilities, and implement runtime security policies. Each layer adds protection against different attack vectors.
Step 1: Create Non-Root User with Proper Ownership
FROM node:18-alpine
# Create non-root user BEFORE copying files
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 -G nodejs
WORKDIR /app
# Copy with correct ownership
COPY --chown=nodejs:nodejs package*.json ./
RUN npm ci --only=production
COPY --chown=nodejs:nodejs . .
# Switch to non-root user
USER nodejs
EXPOSE 3000
CMD ["node", "server.js"]Step 2: Drop Capabilities and Add Security Options
# Run with minimal capabilities
docker run -d \
--name api \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
--security-opt=no-new-privileges:true \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid \
api:secureSecurity flags explained:
--cap-drop=ALL- Remove all Linux capabilities--cap-add=NET_BIND_SERVICE- Only add what’s needed--no-new-privileges- Prevent privilege escalation via setuid--read-only- Immutable filesystem--tmpfs /tmp- Writable temp directory without persistence
Step 3: Complete Secure Dockerfile
# Use specific version, not 'latest'
FROM node:18.19.0-alpine3.19
# Security: Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 -G nodejs
# Security: Set restrictive permissions on workdir
WORKDIR /app
RUN chown nodejs:nodejs /app
# Install dependencies as root, then fix ownership
COPY package*.json ./
RUN npm ci --only=production && \
npm cache clean --force && \
chown -R nodejs:nodejs /app
# Copy application code with correct ownership
COPY --chown=nodejs:nodejs . .
# Security: Switch to non-root user
USER nodejs
# Security: Don't run as PID 1 (use tini or dumb-init)
# Alpine includes tini
ENTRYPOINT ["/sbin/tini", "--"]
EXPOSE 3000
# Use exec form to ensure signals are handled properly
CMD ["node", "server.js"]
# Security labels
LABEL security.privileged="false" \
security.allowPrivilegeEscalation="false"Step 4: Docker Compose with Security Options
version: '3.8'
services:
api:
build: .
ports:
- "3000:3000"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
memory: 256M
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3Step 5: Scan for Vulnerabilities
# Scan image for vulnerabilities
docker scout cves api:secure
# Or use trivy
trivy image api:secure
# Or use grype
grype api:secure
# Fix vulnerabilities by updating base image
# Check for newer Alpine/Node versions with fewer CVEs Security Checklist
| Security Measure | Attack Vector Prevented |
|---|---|
| Non-root user | Privilege escalation, host filesystem access |
| Drop capabilities | Kernel exploits, container escapes |
| Read-only filesystem | Malware persistence, config tampering |
| No-new-privileges | Setuid/setgid exploits |
| Resource limits | DoS attacks, resource exhaustion |
| Minimal base image | Reduced attack surface, fewer CVEs |
| Specific image tags | Supply chain attacks, unexpected changes |
| Vulnerability scanning | Known CVE exploitation |
Runtime Security with Docker
# Full security hardened run command
docker run -d \
--name api \
--user 1001:1001 \
--cap-drop=ALL \
--security-opt=no-new-privileges:true \
--security-opt=apparmor=docker-default \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--memory=512m \
--memory-swap=512m \
--cpus=1 \
--pids-limit=100 \
--restart=on-failure:3 \
--health-cmd="wget -q --spider http://localhost:3000/health" \
--health-interval=30s \
-p 3000:3000 \
api:secure
Practice Question
Why is running a container with --cap-drop=ALL important for security?