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: Hardening Containers

Secure Docker: rootless mode, read-only filesystems, image scanning, and least privilege.

DockerSecurityContainersDevOps

By MinhVo

Introduction

Container security is not a feature you bolt on after deployment. It is a fundamental design consideration that affects every layer of your container stack, from the base image you choose to the permissions you grant and the network policies you enforce. A single misconfigured container can expose your entire infrastructure to attack, making security the most critical aspect of container adoption.

Docker containers share the host operating system's kernel, which means a kernel vulnerability or a container escape can compromise the entire host. Unlike virtual machines, which provide hardware-level isolation, containers rely on Linux kernel features like namespaces, cgroups, and capabilities for isolation. Understanding these mechanisms and how to harden them is essential for running containers in production.

This guide covers the complete spectrum of Docker security hardening: from writing secure Dockerfiles and minimizing image attack surfaces to configuring runtime security policies, implementing secrets management, and integrating vulnerability scanning into your CI/CD pipeline. Every recommendation is backed by practical implementation examples that you can apply immediately.

Security and protection concept

Understanding Container Security: Core Concepts

Container security operates across four layers: the image, the runtime, the orchestration platform, and the host operating system. Each layer has distinct attack vectors and corresponding mitigation strategies. A defense-in-depth approach addresses all layers rather than focusing on any single one.

The Shared Kernel Model

Containers share the host's Linux kernel. This is what makes them lightweight and fast compared to virtual machines, but it also means that a kernel exploit inside a container can potentially affect the host and all other containers. The kernel attack surface includes system calls, device access, and filesystem operations.

Docker uses several kernel features to isolate containers:

  • Namespaces: Provide isolated views of system resources (PID, network, mount, user, UTS, IPC)
  • cgroups: Limit and monitor resource usage (CPU, memory, disk I/O, network bandwidth)
  • Capabilities: Fine-grained privilege control replacing the binary root/non-root distinction
  • Seccomp: Restricts the system calls a container can make
  • AppArmor/SELinux: Mandatory access control policies that restrict file and network access

Linux Capabilities

Traditional Unix security distinguishes between root (UID 0) and unprivileged users. Linux capabilities decompose root privileges into discrete units that can be granted individually. Docker drops many capabilities by default but retains some that containers commonly need.

# View default Docker capabilities
docker run --rm alpine cat /proc/1/status | grep Cap
 
# Drop all capabilities and add only what is needed
docker run --rm \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \
  nginx:alpine
 
# List all available capabilities
docker run --rm alpine sh -c 'capsh --print'

Security Profiles

Docker applies several security profiles by default:

  • Seccomp profile: Blocks 44 of the 300+ available system calls, including those used for container escapes
  • AppArmor profile: Restricts file access and mount operations
  • No-new-privileges: Prevents processes from gaining additional privileges through setuid binaries

Architecture and Design Patterns

Defense in Depth

Effective container security implements multiple overlapping controls so that the failure of any single control does not result in a compromise.

┌─────────────────────────────────────┐
│ Layer 1: Image Security             │
│ - Minimal base images               │
│ - No secrets in layers              │
│ - Vulnerability scanning            │
│ - Signed images                     │
├─────────────────────────────────────┤
│ Layer 2: Build Security             │
│ - Multi-stage builds                │
│ - Non-root user                     │
│ - Read-only filesystem              │
│ - Distroless/scratch bases          │
├─────────────────────────────────────┤
│ Layer 3: Runtime Security           │
│ - Dropped capabilities              │
│ - Seccomp profiles                  │
│ - Resource limits                   │
│ - Read-only root filesystem         │
├─────────────────────────────────────┤
│ Layer 4: Network Security           │
│ - Internal networks                 │
│ - Network policies                  │
│ - TLS everywhere                    │
│ - No exposed unnecessary ports      │
├─────────────────────────────────────┤
│ Layer 5: Orchestration Security     │
│ - Pod security policies             │
│ - RBAC                              │
│ - Secret management                 │
│ - Audit logging                     │
└─────────────────────────────────────┘

Least Privilege Principle

Every container should run with the minimum privileges required to perform its function. This includes running as a non-root user, dropping unnecessary capabilities, using a read-only filesystem, and restricting network access.

Step-by-Step Implementation

Writing Secure Dockerfiles

The foundation of container security starts with the Dockerfile. Every instruction should follow security best practices.

# Secure Node.js Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
 
# Copy only dependency files first (layer caching + minimal context)
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
 
FROM node:20-alpine AS production
ENV NODE_ENV=production
 
# Install security updates
RUN apk update && apk upgrade && apk add --no-cache tini
 
# Create non-root user with explicit UID/GID
RUN addgroup -g 1001 -S appgroup && \
    adduser -S appuser -u 1001 -G appgroup
 
WORKDIR /app
 
# Copy dependencies and application code with correct ownership
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --chown=appuser:appgroup . .
 
# Remove setuid/setgid binaries
RUN find / -perm /6000 -type f -exec chmod a-s {} \; 2>/dev/null || true
 
# Switch to non-root user
USER 1001:1001
 
# Use tini as init process for proper signal handling
ENTRYPOINT ["/sbin/tini", "--"]
 
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD wget --spider http://localhost:3000/health || exit 1
 
CMD ["node", "src/index.js"]

Read-Only Filesystem Configuration

A read-only filesystem prevents attackers from modifying application files, installing backdoors, or writing malicious scripts inside the container.

# docker-compose.yml with security hardening
version: '3.8'
 
services:
  api:
    build: .
    read_only: true
    tmpfs:
      - /tmp:size=100M,noexec,nosuid
      - /app/logs:size=50M
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 128M
    networks:
      - backend
 
  db:
    image: postgres:16-alpine
    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
    networks:
      - backend
 
volumes:
  pgdata:
 
networks:
  backend:
    internal: true

Rootless Docker Mode

Running the Docker daemon itself without root privileges provides an additional layer of security. Rootless mode runs the entire Docker stack in a user namespace.

# Install rootless Docker
dockerd-rootless-setuptool.sh install
 
# Verify rootless mode
docker context use rootless
docker info | grep -i root
# Security Options: rootless
 
# Run containers in rootless mode
docker run --rm alpine id
# uid=0(root) gid=0(root) - appears as root inside container
# but is mapped to unprivileged user on host

Image Scanning with Docker Scout

Docker Scout analyzes images for known vulnerabilities in OS packages and application dependencies.

# Enable Docker Scout
docker scout quickview myapp:latest
 
# Detailed vulnerability report
docker scout cves myapp:latest
 
# Compare vulnerabilities between versions
docker scout compare myapp:v1.0 myapp:v2.0
 
# Recommendations for base image upgrades
docker scout recommendations myapp:latest
 
# Integrate into CI pipeline
docker scout cves --format sarif --output scout-report.sarif myapp:latest
// ci/scout-check.ts - CI integration for vulnerability scanning
import { execSync } from 'child_process';
 
interface VulnerabilityReport {
  critical: number;
  high: number;
  medium: number;
  low: number;
}
 
function scanImage(image: string): VulnerabilityReport {
  const output = execSync(`docker scout cves --format json ${image}`, {
    encoding: 'utf-8'
  });
  
  const report = JSON.parse(output);
  
  return {
    critical: report.vulnerabilities?.filter((v: any) => v.severity === 'critical').length || 0,
    high: report.vulnerabilities?.filter((v: any) => v.severity === 'high').length || 0,
    medium: report.vulnerabilities?.filter((v: any) => v.severity === 'medium').length || 0,
    low: report.vulnerabilities?.filter((v: any) => v.severity === 'low').length || 0
  };
}
 
function enforceSecurityPolicy(image: string, policy: Partial<VulnerabilityReport>): void {
  const report = scanImage(image);
  
  if (policy.critical !== undefined && report.critical > policy.critical) {
    throw new Error(`FAIL: ${report.critical} critical vulnerabilities found (limit: ${policy.critical})`);
  }
  if (policy.high !== undefined && report.high > policy.high) {
    throw new Error(`FAIL: ${report.high} high vulnerabilities found (limit: ${policy.high})`);
  }
  
  console.log(`PASS: ${image} meets security policy`);
  console.log(`  Critical: ${report.critical}, High: ${report.high}, Medium: ${report.medium}, Low: ${report.low}`);
}
 
// Enforce zero critical, zero high vulnerabilities
enforceSecurityPolicy('myapp:latest', { critical: 0, high: 0 });

Secrets Management

Never embed secrets in Docker images. Use Docker secrets, environment variables from secure sources, or mounted secret files.

# docker-compose.yml with Docker secrets
version: '3.8'
 
services:
  api:
    build: .
    secrets:
      - db_password
      - api_key
      - tls_cert
    environment:
      - DB_PASSWORD_FILE=/run/secrets/db_password
      - API_KEY_FILE=/run/secrets/api_key
 
secrets:
  db_password:
    file: ./secrets/db_password.txt
  api_key:
    external: true
  tls_cert:
    file: ./secrets/tls.crt
// Application code reading secrets from files
import fs from 'fs';
 
function readSecret(name: string): string {
  const filePath = process.env[`${name.toUpperCase()}_FILE`];
  
  if (filePath) {
    // Read from Docker secret file
    return fs.readFileSync(filePath, 'utf-8').trim();
  }
  
  // Fallback to environment variable
  const envValue = process.env[name.toUpperCase()];
  if (envValue) {
    return envValue;
  }
  
  throw new Error(`Secret ${name} not found. Set ${name.toUpperCase()}_FILE or ${name.toUpperCase()} environment variable.`);
}
 
const dbPassword = readSecret('DB_PASSWORD');
const apiKey = readSecret('API_KEY');

Network Security Policies

Restrict container network access to only what is required.

version: '3.8'
 
services:
  api:
    build: .
    networks:
      - frontend
      - backend
    dns:
      - 8.8.8.8
    # No access to management network
 
  db:
    image: postgres:16-alpine
    networks:
      - backend
    # Only accessible from backend network
    expose:
      - "5432"
    # Do NOT map to host port
 
  cache:
    image: redis:7-alpine
    networks:
      - backend
    command: redis-server --requirepass ${REDIS_PASSWORD} --protected-mode yes
 
networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true
    ipam:
      config:
        - subnet: 10.10.0.0/24

Real-World Use Cases and Case Studies

Use Case 1: Healthcare Application Compliance

A healthcare technology company needed to meet HIPAA requirements for their containerized application platform. They implemented a comprehensive security hardening strategy: distroless base images (reducing attack surface by 90%), read-only filesystems, dropped capabilities, encrypted overlay networks, and mandatory vulnerability scanning with zero-critical-vulnerability policy. Their container security audit passed on the first attempt, with the auditor noting that their security posture exceeded the compliance requirements.

Use Case 2: Financial Services Container Escape Prevention

After a security research team demonstrated a container escape using a kernel vulnerability at a conference, a financial services company accelerated their container hardening program. They deployed rootless Docker across all development and staging environments, implemented custom seccomp profiles that blocked an additional 20 system calls beyond the Docker default, and added Falco for runtime anomaly detection. When a similar kernel vulnerability was disclosed months later, their containers were not exploitable because the seccomp profile blocked the exploit vector.

Use Case 3: E-Commerce PCI-DSS Compliance

An e-commerce platform processing credit card transactions needed to segment their payment processing containers from general application containers. They implemented network isolation using Docker's internal networks, ensuring that payment processing containers had no direct internet access and could only communicate with the payment gateway and database on specific ports. Combined with image signing and vulnerability scanning, they achieved PCI-DSS Level 1 compliance for their container platform.

Use Case 4: Supply Chain Security

A software company distributing containerized applications to customers implemented Docker Content Trust (DCT) to sign all images. Customers could verify the authenticity and integrity of images before deployment. The signing process was integrated into their CI/CD pipeline, with the signing key stored in a hardware security module (HSM). This prevented supply chain attacks where malicious images could be substituted in the distribution pipeline.

Best Practices for Production

  1. Run as non-root user: Every production container should run as a non-root user with a specific UID/GID. Use USER 1001:1001 in your Dockerfile and ensure all files are owned by this user. This is the single most impactful security hardening step.

  2. Use minimal base images: Choose Alpine, distroless, or scratch base images. Every package in your base image is potential attack surface. A Debian base has 800+ packages; Alpine has approximately 100; distroless has even fewer.

  3. Drop all capabilities and add back only what is needed: Start with --cap-drop ALL and add back specific capabilities with --cap-add. Most web applications need only NET_BIND_SERVICE to bind to ports below 1024.

  4. Enable read-only root filesystem: Use --read-only with tmpfs mounts for directories that need write access. This prevents attackers from modifying application code or installing tools inside a compromised container.

  5. Scan images for vulnerabilities: Integrate Docker Scout, Trivy, or Snyk into your CI/CD pipeline. Establish a policy for vulnerability remediation: critical and high vulnerabilities should block deployment; medium and low should be tracked and remediated within defined SLAs.

  6. Implement secrets management: Never store secrets in images, environment variables visible in docker inspect, or version control. Use Docker secrets, Kubernetes secrets, or external secret managers like HashiCorp Vault or AWS Secrets Manager.

  7. Use security profiles: Apply custom seccomp and AppArm profiles that restrict system calls and file access beyond Docker's defaults. Test applications with restrictive profiles and relax only as needed.

  8. Monitor runtime behavior: Deploy runtime security tools like Falco, Sysdig, or Aqua Security to detect anomalous container behavior such as unexpected network connections, file modifications, or process execution.

Common Pitfalls and Solutions

PitfallImpactSolution
Running containers as rootContainer escape gives attacker root on hostAdd USER instruction with explicit UID/GID in Dockerfile
Secrets in environment variablesExposed via docker inspect, process listings, logsUse Docker secrets or mounted secret files
Using --privileged flagGrants all capabilities and device accessDrop all capabilities and add back only what is needed
Unscanned base imagesKnown vulnerabilities in productionIntegrate image scanning into CI/CD pipeline with blocking policy
Writable root filesystemAttackers can modify application codeUse --read-only with tmpfs for writable directories
Default bridge networkWeak isolation, no DNSUse user-defined networks with appropriate isolation
No resource limitsDoS through resource exhaustionSet memory and CPU limits for all production containers
Using latest tagNon-reproducible builds, potential for pulling compromised imagesPin to specific image digests in production

Performance Optimization

Security hardening can impact performance. Understanding these trade-offs helps you make informed decisions.

// Performance impact measurement for security features
interface SecurityBenchmark {
  feature: string;
  overhead: string;
  recommendation: string;
}
 
const benchmarks: SecurityBenchmark[] = [
  {
    feature: 'Read-only filesystem',
    overhead: '<1% (minimal I/O change)',
    recommendation: 'Always enable for web applications'
  },
  {
    feature: 'Dropped capabilities',
    overhead: 'Zero (process-level restriction)',
    recommendation: 'Always drop all, add back as needed'
  },
  {
    feature: 'Seccomp profile',
    overhead: '<2% per system call (filter check)',
    recommendation: 'Use Docker default or custom restrictive profile'
  },
  {
    feature: 'AppArmor profile',
    overhead: '<1% per file operation',
    recommendation: 'Enable with Docker default profile'
  },
  {
    feature: 'User namespaces (rootless)',
    overhead: '2-5% for I/O-heavy workloads',
    recommendation: 'Accept for improved security boundary'
  },
  {
    feature: 'Image scanning (CI)',
    overhead: '30-120 seconds per scan',
    recommendation: 'Run asynchronously, block on critical findings'
  }
];

Comparison with Alternatives

Security FeatureDocker NativeKubernetesPodmangVisorKata Containers
User namespacesRootless modeUser namespace supportDefault rootlessSandboxVM isolation
SeccompDefault profilePod security standardsDefault profileSystem call interceptionVM boundary
Image scanningDocker ScoutMultiple integrationsCompatibleCompatibleCompatible
Network policiesBasicCalico/CiliumCompatibleManagedVM networking
Isolation modelNamespaces + cgroupsNamespaces + cgroupsNamespaces + cgroupsUser-space kernelHardware VM
OverheadMinimalMinimalMinimalModerate (5-15%)High (VM startup)
Best forGeneral workloadsProduction orchestrationSecurity-first desktopUntrusted codeStrong isolation

Advanced Patterns and Techniques

Custom Seccomp Profile

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": ["SCMP_ARCH_X86_64"],
  "syscalls": [
    {
      "names": [
        "accept", "accept4", "access", "bind", "brk", "chdir",
        "chmod", "clock_gettime", "close", "connect", "dup",
        "dup2", "epoll_create", "epoll_ctl", "epoll_wait",
        "execve", "exit", "exit_group", "fcntl", "fstat",
        "futex", "getcwd", "getdents", "getpid", "getuid",
        "ioctl", "listen", "lseek", "madvise", "mmap",
        "mprotect", "munmap", "nanosleep", "newfstatat",
        "open", "openat", "pipe", "poll", "prctl",
        "pread64", "read", "readv", "recvfrom", "recvmsg",
        "rename", "rt_sigaction", "rt_sigprocmask",
        "sendmsg", "sendto", "set_robust_list", "set_tid_address",
        "setsockopt", "shutdown", "sigaltstack", "socket",
        "stat", "statfs", "tgkill", "umask", "uname",
        "unlink", "wait4", "write", "writev"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}
# Apply custom seccomp profile
docker run --rm --security-opt seccomp=custom-seccomp.json myapp:latest

Runtime Security Monitoring with Falco

# falco-rules.yml - Custom rules for container security
- rule: Unexpected Network Connection from Container
  desc: Detect outbound connections to unexpected destinations
  condition: >
    outbound and container and
    not (fd.sip in (allowed_destinations))
  output: >
    Unexpected outbound connection from container
    (command=%proc.cmdline connection=%fd.name)
  priority: WARNING
 
- rule: Shell Spawned in Container
  desc: Detect interactive shell in production container
  condition: >
    spawned_process and container and
    proc.name in (bash, sh, zsh, ash)
  output: >
    Shell spawned in container
    (user=%user.name command=%proc.cmdline container=%container.name)
  priority: CRITICAL

Testing Strategies

# Security test suite for Docker images
test_image_security() {
  local image=$1
  
  echo "=== Security Tests for $image ==="
  
  # Test 1: Non-root user
  local uid=$(docker run --rm "$image" id -u)
  if [ "$uid" != "0" ]; then
    echo "PASS: Running as non-root (UID: $uid)"
  else
    echo "FAIL: Running as root"
  fi
  
  # Test 2: No shell access
  if ! docker run --rm "$image" sh -c "echo test" 2>/dev/null; then
    echo "PASS: No shell available"
  else
    echo "WARN: Shell is available in image"
  fi
  
  # Test 3: Read-only filesystem
  if docker run --rm --read-only "$image" echo "ok" 2>/dev/null; then
    echo "PASS: Works with read-only filesystem"
  else
    echo "FAIL: Requires writable filesystem"
  fi
  
  # Test 4: Minimal capabilities
  if docker run --rm --cap-drop ALL "$image" echo "ok" 2>/dev/null; then
    echo "PASS: Works with all capabilities dropped"
  else
    echo "WARN: Requires additional capabilities"
  fi
  
  # Test 5: No setuid binaries
  local suid_count=$(docker run --rm "$image" find / -perm /6000 -type f 2>/dev/null | wc -l)
  if [ "$suid_count" -eq 0 ]; then
    echo "PASS: No setuid/setgid binaries"
  else
    echo "WARN: $suid_count setuid/setgid binaries found"
  fi
  
  # Test 6: Vulnerability scan
  docker scout cves "$image" 2>/dev/null | grep -c "critical" | {
    read count
    if [ "$count" -eq 0 ]; then
      echo "PASS: No critical vulnerabilities"
    else
      echo "FAIL: $count critical vulnerabilities"
    fi
  }
}
 
test_image_security "myapp:production"

Future Outlook

Container security is evolving toward zero-trust architectures where every container interaction is authenticated and authorized. eBPF-based security tools like Cilium Tetragon and Falco are enabling real-time runtime monitoring with minimal performance overhead. WebAssembly (Wasm) containers offer a fundamentally different security model with capability-based access control at the instruction level.

The software supply chain security movement, driven by frameworks like SLSA (Supply-chain Levels for Software Artifacts) and Sigstore for image signing, is becoming a standard expectation rather than an advanced practice. Organizations that invest in container security fundamentals today will be well-positioned for these evolving requirements.

Conclusion

Container security requires a defense-in-depth approach spanning the entire lifecycle from build to runtime to monitoring. No single measure is sufficient, but the combination of hardening techniques creates a robust security posture.

Key takeaways:

  1. Run as non-root with explicit UID/GID. This is the single most impactful security hardening step and costs nothing in terms of complexity or performance.
  2. Use minimal base images and scan them for vulnerabilities in your CI/CD pipeline. Block deployments with critical or high severity findings.
  3. Drop all Linux capabilities and add back only what your application needs. Most web applications require only NET_BIND_SERVICE.
  4. Apply read-only filesystems with targeted tmpfs mounts for directories that need write access.
  5. Manage secrets properly using Docker secrets, mounted files, or external secret managers. Never embed secrets in images or environment variables.
  6. Segment networks using user-defined bridge networks with internal-only access for backend services.
  7. Monitor runtime behavior with security tools that detect anomalous activity inside running containers.

Security is not a destination but a continuous practice. Regularly review your container security posture, update base images to patch known vulnerabilities, and adapt your security policies as new threats and mitigations emerge.