MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Docker Security Best Practices

Secure Docker containers: non-root users, image scanning, secrets management, and read-only filesystems.

DockerSecurityDevOpsContainers

By MinhVo

Introduction

Every Docker container is a potential entry point into your infrastructure. A single vulnerable container can expose your host system, your data, and your other services to attackers. The convenience of containers comes with the responsibility to secure them properly, and the consequences of neglecting container security range from data breaches to regulatory non-compliance.

Docker security is not a single configuration setting. It spans the entire container lifecycle: the base image you choose determines your starting attack surface, the Dockerfile instructions define what software runs inside, the runtime configuration controls what the container can do, and the network policies govern what it can reach. Each layer of this stack requires deliberate security decisions.

This guide provides practical, actionable security measures organized by lifecycle stage. Every recommendation includes implementation code and explains the threat it mitigates. By following these practices systematically, you can run containers in production with confidence that your security posture meets industry standards.

Cybersecurity concept

Understanding Container Threats: Core Concepts

Before hardening containers, you need to understand the attack vectors. Container threats fall into four categories: image vulnerabilities, runtime escapes, misconfigurations, and supply chain attacks.

Image Vulnerabilities

Base images contain hundreds of software packages, each potentially harboring known vulnerabilities. A node:18 image on Debian Bullseye contains over 400 packages with dozens of known CVEs at any given time. These vulnerabilities are not theoretical; attackers actively scan container registries for images with known exploits.

Vulnerability scanners like Docker Scout, Trivy, and Snyk compare the packages in your image against CVE databases and report findings by severity. A critical vulnerability in an exposed library like OpenSSL can be remotely exploitable, making image scanning an essential pre-deployment gate.

Runtime Container Escapes

Container escapes occur when a process inside a container gains access to the host system. The most common escape vectors involve kernel vulnerabilities, privileged container configurations, and mounted host paths. The --privileged flag disables most container isolation and should never be used in production.

Misconfigurations

The most common container security incidents result from misconfigurations rather than software vulnerabilities. Running as root, exposing unnecessary ports, storing secrets in images, and using default networks are all misconfigurations that create exploitable conditions.

Supply Chain Attacks

Attackers increasingly target the container supply chain by poisoning base images in public registries, compromising CI/CD pipelines, and injecting malicious code into dependencies. Image signing and provenance verification are the primary defenses against these attacks.

Architecture and Design Patterns

The Security Lifecycle

Container security follows a lifecycle that maps to the software delivery pipeline. Each stage has specific security controls.

Build Time:
  ├── Base image selection (minimal, verified)
  ├── Dockerfile hardening (non-root, multi-stage)
  ├── Dependency pinning (lock files, digests)
  └── Vulnerability scanning (CI gate)

Registry Time:
  ├── Image signing (Docker Content Trust)
  ├── Access control (authentication, authorization)
  └── Vulnerability monitoring (continuous scanning)

Deploy Time:
  ├── Runtime configuration (capabilities, seccomp)
  ├── Network policies (segmentation, encryption)
  ├── Resource limits (CPU, memory)
  └── Secrets injection (external secret manager)

Runtime:
  ├── Behavior monitoring (Falco, Sysdig)
  ├── Log aggregation (audit trails)
  └── Incident response (forensics, recovery)

Minimal Image Strategy

The relationship between image contents and attack surface is direct: fewer installed packages means fewer potential vulnerabilities. The goal is to include only the software your application needs to run.

# Attack surface comparison
FROM debian:bookworm    # ~800 packages, ~120MB, ~50 CVEs typical
FROM python:3.12-slim   # ~100 packages, ~130MB, ~15 CVEs typical  
FROM python:3.12-alpine # ~30 packages, ~50MB, ~5 CVEs typical
FROM scratch            # 0 packages, ~0MB, 0 CVEs (static binary only)

Step-by-Step Implementation

Hardening the Dockerfile

Every Dockerfile instruction is a security decision. Here is a comprehensive hardened Dockerfile for a Node.js application.

# Pin exact versions for reproducibility
FROM node:20.10.0-alpine3.19 AS builder
WORKDIR /app
 
# Copy dependency files with minimal context
COPY package.json package-lock.json ./
 
# Install production dependencies only
RUN npm ci --omit=dev && \
    # Remove npm cache
    npm cache clean --force && \
    # Remove unnecessary files
    rm -rf /tmp/*
 
FROM node:20.10.0-alpine3.19 AS production
 
# Install security updates and tini for init process
RUN apk update && \
    apk upgrade && \
    apk add --no-cache tini && \
    rm -rf /var/cache/apk/*
 
# Create non-root user with explicit IDs
RUN addgroup -g 1001 -S appgroup && \
    adduser -S -u 1001 -G appgroup -h /app appuser
 
WORKDIR /app
 
# Copy application with correct ownership
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --chown=appuser:appgroup package.json ./
COPY --chown=appuser:appgroup src/ ./src/
 
# Remove setuid/setgid permissions from all binaries
RUN find / -type f \( -perm /4000 -o -perm /2000 \) -exec chmod ug-s {} \; 2>/dev/null || true
 
# Switch to non-root user
USER 1001:1001
 
# Use tini as PID 1 for proper signal handling
ENTRYPOINT ["/sbin/tini", "--"]
 
EXPOSE 3000
 
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
 
CMD ["node", "src/index.js"]

Runtime Security Configuration

Docker runtime flags control what a container can do. Production containers should run with the most restrictive configuration possible.

# docker-compose.yml with comprehensive security hardening
version: '3.8'
 
services:
  api:
    build:
      context: .
      target: production
    ports:
      - "3000:3000"
    
    # Filesystem security
    read_only: true
    tmpfs:
      - /tmp:size=100M,noexec,nosuid,nodev
      - /app/.cache:size=50M
    
    # Process security
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    
    # Resource limits
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M
          pids: 100
        reservations:
          cpus: '0.25'
          memory: 256M
    
    # Health monitoring
    healthcheck:
      test: ["CMD", "wget", "--spider", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    
    # Logging
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "5"
    
    # Network isolation
    networks:
      - backend
    
    # Restart policy
    restart: unless-stopped
 
  db:
    image: postgres:16.1-alpine3.19
    read_only: true
    tmpfs:
      - /tmp:size=100M
      - /var/run/postgresql:size=10M
    volumes:
      - pgdata:/var/lib/postgresql/data
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - DAC_OVERRIDE
      - FOWNER
      - SETGID
      - SETUID
      - NET_BIND_SERVICE
    networks:
      - backend
    environment:
      - POSTGRES_PASSWORD_FILE=/run/secrets/db_password
    secrets:
      - db_password
 
  redis:
    image: redis:7.2-alpine3.19
    read_only: true
    command: >
      redis-server
      --requirepass ${REDIS_PASSWORD}
      --protected-mode yes
      --rename-command FLUSHALL ""
      --rename-command FLUSHDB ""
      --rename-command CONFIG ""
    cap_drop:
      - ALL
    cap_add:
      - SETUID
      - SETGID
    networks:
      - backend
 
volumes:
  pgdata:
    driver: local
 
networks:
  backend:
    driver: bridge
    internal: true
 
secrets:
  db_password:
    file: ./secrets/db_password.txt

Image Scanning in CI/CD

# GitHub Actions workflow for container security scanning
name: Container Security Scan
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  security-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - name: Build image
        run: docker build -t myapp:${{ github.sha }} .
 
      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: myapp:${{ github.sha }}
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'  # Fail build on critical/high findings
 
      - name: Run Docker Scout
        uses: docker/scout-action@v1
        with:
          command: cves
          image: myapp:${{ github.sha }}
          only-severities: critical,high
          exit-code: true
 
      - name: Test container security
        run: |
          # Verify non-root user
          UID=$(docker run --rm myapp:${{ github.sha }} id -u)
          [ "$UID" != "0" ] || (echo "FAIL: Running as root" && exit 1)
          
          # Verify read-only filesystem works
          docker run --rm --read-only myapp:${{ github.sha }} echo "ok"
          
          # Verify minimal capabilities work
          docker run --rm --cap-drop ALL myapp:${{ github.sha }} echo "ok"
          
          echo "All security tests passed"

Secrets Management Patterns

// Secure secrets loading for Node.js applications
import fs from 'fs';
import path from 'path';
 
interface SecretConfig {
  name: string;
  required: boolean;
  fallbackEnvVar?: string;
}
 
const secrets: SecretConfig[] = [
  { name: 'DB_PASSWORD', required: true },
  { name: 'API_KEY', required: true },
  { name: 'JWT_SECRET', required: true },
  { name: 'REDIS_PASSWORD', required: false, fallbackEnvVar: 'REDIS_PASSWORD' }
];
 
class SecretManager {
  private cache = new Map<string, string>();
 
  getSecret(name: string): string {
    if (this.cache.has(name)) {
      return this.cache.get(name)!;
    }
 
    const value = this.loadSecret(name);
    if (value) {
      this.cache.set(name, value);
    }
    return value;
  }
 
  private loadSecret(name: string): string {
    // Docker secrets path
    const secretPath = `/run/secrets/${name.toLowerCase()}`;
    if (fs.existsSync(secretPath)) {
      return fs.readFileSync(secretPath, 'utf-8').trim();
    }
 
    // Environment variable file path
    const fileEnvVar = `${name}_FILE`;
    const filePath = process.env[fileEnvVar];
    if (filePath && fs.existsSync(filePath)) {
      return fs.readFileSync(filePath, 'utf-8').trim();
    }
 
    // Direct environment variable
    const envValue = process.env[name];
    if (envValue) {
      return envValue;
    }
 
    return '';
  }
 
  validateRequired(): void {
    const config = secrets.find(s => s.name === 'DB_PASSWORD');
    for (const secret of secrets) {
      const value = this.getSecret(secret.name);
      if (secret.required && !value) {
        throw new Error(`Required secret ${secret.name} is not configured`);
      }
    }
  }
}
 
export const secretManager = new SecretManager();

Real-World Use Cases and Case Studies

Use Case 1: SaaS Platform Security Audit

A SaaS platform serving enterprise customers underwent a security audit that identified 23 container security issues. The most critical findings were: containers running as root (12 services), secrets embedded in Docker images (5 services), and no vulnerability scanning in CI (all services). Over 8 weeks, they addressed all findings by implementing non-root users, Docker secrets, and Trivy scanning with a zero-critical-vulnerability gate. The platform passed its re-audit and achieved SOC 2 Type II certification.

Use Case 2: Kubernetes Cluster Compromise Prevention

A DevSecOps team detected anomalous behavior in their Kubernetes cluster: a container was attempting to contact an external cryptocurrency mining pool. Investigation revealed that a developer had deployed a container from a public image that included a cryptominer. The incident was contained because the container was running with dropped capabilities, read-only filesystem, and no outbound network access to the mining pool. The container was terminated, and the team implemented mandatory image scanning for all deployments.

Use Case 3: Regulatory Compliance for FinTech

A fintech company needed to meet SOC 2, PCI-DSS, and GDPR requirements simultaneously. They implemented a comprehensive container security program: custom seccomp profiles blocking 60% of available system calls, Falco for runtime anomaly detection, encrypted overlay networks between services, and automated vulnerability scanning with 24-hour SLA for critical findings. The security program was documented and auditable, satisfying all three regulatory frameworks.

Use Case 4: Supply Chain Attack Mitigation

A security team discovered that a popular Node.js base image on Docker Hub had been compromised, with a cryptocurrency miner embedded in the image layers. Organizations using this image were unknowingly running the miner. Companies that had implemented image signing with Docker Content Trust and verified image provenance were unaffected because they only deployed signed images from verified publishers. This incident accelerated the adoption of image signing across the industry.

Best Practices for Production

  1. Never use --privileged: This flag disables virtually all container isolation. If your container needs specific capabilities, add them individually with --cap-add. If it needs device access, use specific device mappings.

  2. Pin image versions by digest: Use image:tag@sha256:abc123... to pin images to exact content. Tag-based references can be updated to point to different content, enabling supply chain attacks.

  3. Implement image signing: Use Docker Content Trust or Sigstore/Cosign to sign images during CI/CD. Verify signatures before deployment. Store signing keys in HSMs or cloud KMS.

  4. Use read-only root filesystem: Prevent runtime file modifications by using --read-only. Use tmpfs mounts for directories that need write access, with noexec,nosuid,nodev flags.

  5. Implement resource limits: Set CPU, memory, and PID limits for all production containers. This prevents resource exhaustion attacks and ensures fair resource sharing.

  6. Rotate secrets regularly: Implement automated secret rotation for database passwords, API keys, and certificates. Use external secret managers that support rotation.

  7. Monitor container runtime: Deploy runtime security monitoring to detect anomalous behavior. Alert on unexpected process execution, network connections, and file modifications.

  8. Maintain an image inventory: Track all images running in production, their base images, and their vulnerability status. Automate remediation when new vulnerabilities are disclosed in base images.

Common Pitfalls and Solutions

PitfallImpactSolution
Running as root (UID 0)Container escape gives host root accessUSER 1001:1001 in Dockerfile
Using --privileged flagComplete host access from containerDrop all capabilities, add only needed ones
Secrets in ENV or image layersVisible via docker inspect and image historyUse Docker secrets or external secret manager
Unscanned images in productionKnown vulnerabilities exploitableCI/CD scanning gate with zero-critical policy
Default bridge networkWeak isolation between containersUser-defined networks with internal: true for backends
Writable root filesystemAttackers can modify application code--read-only with tmpfs for writable dirs
No resource limitsContainer can exhaust host resources--memory, --cpus, --pids-limit
Using latest or unversioned tagsNon-reproducible, potentially compromised imagesPin to specific version + digest

Performance Optimization

Security measures have measurable performance impact. Understanding these costs helps you make informed trade-offs.

Security MeasureCPU ImpactMemory ImpactI/O ImpactRecommendation
Non-root userNoneNoneNoneAlways apply
Read-only filesystemNoneNoneMinimalAlways apply
Dropped capabilitiesNoneNoneNoneAlways apply
Seccomp profile<2%NoneNoneAlways apply
AppArmor profile<1%None<1%Apply when available
Image scanning (CI)N/AN/A30-120s per scanGate deployment
Runtime monitoring1-3%50-100MBMinimalApply in production
// Measure security overhead
import { performance } from 'perf_hooks';
 
async function measureOverhead() {
  const iterations = 1000;
  
  // Baseline: no security constraints
  const baselineStart = performance.now();
  for (let i = 0; i < iterations; i++) {
    execSync('docker run --rm alpine echo test');
  }
  const baselineTime = performance.now() - baselineStart;
  
  // Hardened: security constraints applied
  const hardenedStart = performance.now();
  for (let i = 0; i < iterations; i++) {
    execSync('docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges alpine echo test');
  }
  const hardenedTime = performance.now() - hardenedStart;
  
  const overhead = ((hardenedTime - baselineTime) / baselineTime) * 100;
  console.log(`Security overhead: ${overhead.toFixed(2)}%`);
}

Comparison with Alternatives

Security ApproachDocker NativeKubernetes PSP/PSSgVisorKata Containers
Isolation levelNamespace-basedPolicy-enforcedUser-space kernelHardware VM
Performance overhead<5%<5%5-15%10-30%
Security strengthGoodGoodStrongVery strong
Configuration complexityLowModerateModerateHigh
CompatibilityFullFull~90% syscallsFull
Best forGeneral productionMulti-tenant K8sUntrusted codeStrongest isolation
Runtime monitoringFalco, SysdigBuilt-in policiesBuilt-inVM-level tools

Advanced Patterns and Techniques

Distroless Production Images

Distroless images contain only the application runtime and its dependencies, without package managers, shells, or other OS utilities. This dramatically reduces attack surface.

# Distroless Node.js image
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
 
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app .
CMD ["src/index.js"]
# Verify distroless has no shell
docker run --rm -it gcr.io/distroless/nodejs20-debian12 /bin/sh
# Error: exec: "/bin/sh": not found
 
# Debug distroless with debug tag
docker run --rm -it gcr.io/distroless/nodejs20-debian12:debug /bin/sh
# Works (debug tag includes busybox shell)

Falco Runtime Security Rules

# Custom Falco rules for container security monitoring
- rule: Terminal Shell in Container
  desc: Detect shell access in production containers
  condition: >
    spawned_process and container and
    shell_procs and
    not k8s.ns.name in (kube-system, monitoring)
  output: >
    Shell spawned in container
    (user=%user.name container=%container.name
     shell=%proc.name parent=%proc.pname
     command=%proc.cmdline)
  priority: CRITICAL
 
- rule: Sensitive File Access in Container
  desc: Detect access to sensitive files
  condition: >
    open_read and container and
    (fd.name startswith /etc/shadow or
     fd.name startswith /etc/passwd or
     fd.name contains .ssh)
  output: >
    Sensitive file accessed in container
    (file=%fd.name user=%user.name container=%container.name)
  priority: WARNING
 
- rule: Unexpected Outbound Connection
  desc: Detect outbound connections to non-allowed destinations
  condition: >
    outbound and container and
    not (fd.sip in (internal_ips))
  output: >
    Unexpected outbound connection
    (command=%proc.cmdline connection=%fd.name container=%container.name)
  priority: WARNING

Testing Strategies

#!/bin/bash
# container-security-test.sh - Comprehensive security validation
 
IMAGE=$1
FAILURES=0
 
assert_pass() {
  echo "✓ $1"
}
 
assert_fail() {
  echo "✗ $1"
  FAILURES=$((FAILURES + 1))
}
 
echo "=== Container Security Tests for $IMAGE ==="
 
# Test: Non-root user
UID_CHECK=$(docker run --rm "$IMAGE" id -u 2>/dev/null)
if [ "$UID_CHECK" != "0" ]; then
  assert_pass "Running as non-root user (UID: $UID_CHECK)"
else
  assert_fail "Running as root (UID: 0)"
fi
 
# Test: Read-only filesystem
if docker run --rm --read-only "$IMAGE" echo ok >/dev/null 2>&1; then
  assert_pass "Works with read-only filesystem"
else
  assert_fail "Requires writable root filesystem"
fi
 
# Test: Minimal capabilities
if docker run --rm --cap-drop ALL "$IMAGE" echo ok >/dev/null 2>&1; then
  assert_pass "Works with all capabilities dropped"
else
  assert_fail "Requires additional capabilities"
fi
 
# Test: No-new-privileges
if docker run --rm --security-opt no-new-privileges:true "$IMAGE" echo ok >/dev/null 2>&1; then
  assert_pass "Works with no-new-privileges"
else
  assert_fail "Requires privilege escalation"
fi
 
# Test: Resource limits
if docker run --rm --memory 256m --cpus 0.5 "$IMAGE" echo ok >/dev/null 2>&1; then
  assert_pass "Works within resource limits"
else
  assert_fail "Cannot run within resource limits"
fi
 
# Test: No setuid binaries
SUID_COUNT=$(docker run --rm "$IMAGE" sh -c "find / -type f -perm -4000 2>/dev/null | wc -l" 2>/dev/null || echo "N/A")
if [ "$SUID_COUNT" = "0" ] || [ "$SUID_COUNT" = "N/A" ]; then
  assert_pass "No setuid binaries found"
else
  assert_fail "Found $SUID_COUNT setuid binaries"
fi
 
echo ""
echo "=== Results: $FAILURES failures ==="
exit $FAILURES

Future Outlook

Container security is converging with broader cloud-native security trends. eBPF-based runtime monitoring provides kernel-level visibility with minimal overhead. Software Bill of Materials (SBOM) requirements are becoming standard for supply chain transparency. Zero-trust architectures are being applied at the container level, with every inter-container communication authenticated and authorized.

WebAssembly (Wasm) containers offer a fundamentally different security model where each capability must be explicitly granted at the instruction level. As Wasm runtime support matures in Docker and containerd, it may provide stronger security guarantees than traditional Linux containers for certain workloads.

Conclusion

Docker security is a continuous practice, not a one-time configuration. The most effective approach layers multiple controls across the build, deploy, and runtime phases.

Essential security measures for every production container:

  1. Run as non-root with explicit UID/GID
  2. Use minimal base images (Alpine, distroless, or scratch)
  3. Drop all capabilities and add back only what is needed
  4. Enable read-only root filesystem with targeted tmpfs mounts
  5. Scan images for vulnerabilities in CI/CD with blocking gates
  6. Manage secrets externally using Docker secrets or external managers
  7. Set resource limits to prevent resource exhaustion attacks
  8. Monitor runtime behavior with anomaly detection tools

No single measure is sufficient. The combination of these practices creates a defense-in-depth posture that protects against the full spectrum of container threats, from known vulnerabilities to zero-day exploits and supply chain compromises.