← Back to Blog DevOps & Security

Docker Security Best Practices (2026): A Complete Hardening Guide

• 15 min read

Securing Docker containers requires a proactive, defense-in-depth approach that goes far beyond simply packaging your application into an image. In 2026, container escapes, privilege escalation, and supply chain attacks have become more sophisticated, making Docker security best practices an absolute necessity for DevOps engineers and sysadmins managing production environments. By default, Docker containers run with root privileges and a generous set of Linux capabilities, which is a recipe for disaster if an attacker manages to compromise your application code. Implementing proper hardening techniques ensures that even if a container is breached, the underlying host system remains protected. If you want to ensure your configurations are sound right out of the gate, use our free Docker Compose Validator to instantly catch security misconfigurations locally in your browser. Our philosophy at ZeroData Tools is privacy-first—no uploads, local processing only.

In this comprehensive guide, we will explore how to build a secure docker container by adopting a non-root execution model, enforcing a read-only root filesystem docker architecture, implementing a complete docker capabilities drop, optimizing image footprints, and systematically scanning for vulnerabilities. Whether you are running single containers on a virtual private server or orchestrating thousands of microservices, these five foundational pillars will drastically reduce your attack surface.

🚀 Quick Summary: Top 5 Container Hardening Rules

  1. Run as Non-Root: Never run your application as the root user. Create a dedicated user in your Dockerfile.
  2. Read-Only Filesystem: Use read_only: true to prevent attackers from writing malware or scripts to the container's disk.
  3. Drop Capabilities: Use --cap-drop=ALL to strip kernel privileges, only adding back exactly what you need.
  4. Use Minimal Images: Build from Alpine, Scratch, or Distroless images to reduce the available tools for attackers (living-off-the-land techniques).
  5. Scan Constantly: Integrate tools like Trivy or Grype into your CI/CD pipeline to catch CVEs before deployment.

1. The Foundation: Run Docker as Non-Root User

One of the most pervasive and dangerous anti-patterns in containerized environments is running the container processes as the root user. By default, when you start a Docker container, the process inside runs as root (UID 0). While Docker uses Linux namespaces to isolate this root user from the host's root user, the isolation is not absolute. If an attacker exploits a vulnerability in your application (such as a Remote Code Execution flaw) and gains a shell, they operate as root inside the container. If they subsequently discover a container escape vulnerability in the Linux kernel or the container runtime, they will likely retain those root privileges on the host machine.

To run docker as non-root, you must explicitly create a less privileged user within your Dockerfile and instruct the runtime to use it. This ensures that even if a breach occurs, the attacker is constrained by the limited permissions of that standard user account.

Implementing Non-Root in Your Dockerfile

When building your image, you should create a specific user and group for your application. Here is how you can implement this in an Alpine-based Node.js container:

# Use a minimal base image
FROM node:22-alpine

# Set the working directory
WORKDIR /usr/src/app

# Create a system group and user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Copy application files and set ownership
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN chown -R appuser:appgroup /usr/src/app

# Switch to the non-root user
USER appuser

# Expose port and define entrypoint
EXPOSE 3000
CMD ["node", "server.js"]

By appending the USER appuser directive, any subsequent commands—including the final CMD or ENTRYPOINT—will execute under this restricted context. It is critical to ensure that all necessary files and directories have the correct ownership before switching users, as the non-root user will not be able to modify root-owned files.

Enforcing Non-Root at Runtime

Even if an image was not explicitly built with a non-root user, you can force it to run as an unprivileged user at runtime using the --user flag in the Docker CLI or the user key in Docker Compose. However, this often leads to permission denied errors if the image expects to write to directories owned by root.

services:
  web:
    image: my-node-app:latest
    user: "1000:1000"
    ports:
      - "3000:3000"

When implementing this, managing file permissions for mounted volumes can become a headache. If you are struggling with UID/GID mapping between the host and the container, you can leverage our Docker Volume Permissions Helper to generate the exact chown and chmod commands needed to align your host directories with your non-root container users safely.

2. Immutable Infrastructure: Read-Only Root Filesystem Docker Configuration

When an attacker exploits a vulnerability to gain execution capabilities within your container, one of their first actions is typically to download additional payloads, drop backdoors, or modify existing scripts to establish persistence. If the container's filesystem is writable, they have free rein to modify the environment.

Implementing a read-only root filesystem docker configuration fundamentally neutralizes these tactics. By mounting the entire root filesystem as read-only, you enforce true immutable infrastructure. If the application attempts to write to the disk (and if a hacker attempts to download malware), the OS blocks the operation.

How to Make the Filesystem Read-Only

Applying this restriction is remarkably simple in both the Docker CLI and Docker Compose. However, most applications expect to write something to disk—whether it's PID files, temporary caches, or application logs. To accommodate these legitimate operations without compromising the entire filesystem, you combine the read-only flag with temporary in-memory filesystems (tmpfs) for specific paths.

services:
  nginx:
    image: nginx:alpine
    # Lock down the root filesystem
    read_only: true
    # Provide temporary write access where explicitly needed
    tmpfs:
      - /tmp
      - /var/run
      - /var/cache/nginx
    ports:
      - "80:80"

In this configuration, the Nginx container is heavily locked down. It can serve files and execute its primary function, but the underlying OS cannot be altered. If you want to master more advanced Compose configurations like this, be sure to read our Complete Guide to Docker Compose, which dives deep into structuring resilient production environments.

When adopting read-only filesystems, you must thoroughly profile your application to discover which directories require write access. Monitor your container logs for "Read-only file system" errors during testing, and selectively mount tmpfs or persistent volumes to those specific paths. The goal is to grant write access exclusively where it is functionally required, leaving the rest of the container immutable.

3. Stripping Kernel Privileges: The Docker Capabilities Drop

Linux capabilities divide the privileges traditionally associated with the root user into distinct units. Instead of granting blanket root access, capabilities allow you to grant granular permissions—such as the ability to bind to privileged ports (CAP_NET_BIND_SERVICE) or the ability to alter file ownership (CAP_CHOWN).

By default, Docker drops many dangerous capabilities but still retains a subset of 14 default capabilities to ensure broad compatibility with most applications. However, most modern web applications and microservices require exactly zero of these capabilities to function correctly.

A fundamental docker capabilities drop involves stripping all privileges using --cap-drop=ALL and then explicitly whitelisting only the exact capabilities your application requires. This drastically reduces the impact of a container escape.

Implementing a Complete Capabilities Drop

For a standard web application running on an unprivileged port (e.g., port 8080), the configuration in Docker Compose looks like this:

services:
  api:
    image: my-api:latest
    cap_drop:
      - ALL
    # Notice we don't use cap_add because an API doesn't need kernel privileges!
    ports:
      - "8080:8080"

If your application absolutely must bind to a port under 1024 (like port 80 or 443), you would drop everything and explicitly add back the single required capability:

services:
  webserver:
    image: nginx:alpine
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    ports:
      - "80:80"

Why This Matters for Security

Consider the CAP_NET_RAW capability, which is granted by default in Docker. This capability allows a process to craft raw network packets. While useful for utilities like ping, a standard web server does not need this. If an attacker compromises your container, CAP_NET_RAW allows them to perform ARP spoofing on the Docker bridge network, sniff traffic from other containers, or launch localized ping sweeps to map your internal network topology. By dropping all capabilities, you strip the attacker of these native Linux tools, heavily restricting their lateral movement capabilities.

4. Shrink the Attack Surface: Build Minimal Images

The base image you choose dictates the initial size of your attack surface. Standard base images like node:22 or python:3.11 are built on full Debian or Ubuntu distributions. They include package managers, shells (bash), network utilities (curl, wget), and sometimes even compilers (gcc). If an attacker gains a shell in a container using these images, they have a massive toolkit readily available to download payloads, compile exploits, and move laterally. This is known as "Living off the Land."

To mitigate this, a secure docker container should be built from minimal base images like Alpine Linux or Distroless images.

Alpine Linux vs. Distroless

Alpine Linux is a lightweight Linux distribution built around musl libc and BusyBox. It is significantly smaller than Debian (often around 5MB base size) and excludes a vast majority of unnecessary utilities. While it still includes a shell and a package manager (apk), it is a massive improvement over standard images.

Distroless images, pioneered by Google, take this a step further. They contain only your application and its strict runtime dependencies. They do not contain package managers, shells, or any other utilities. If an attacker compromises a Distroless container, they cannot even spawn a shell or run ls to explore the filesystem.

Using Multi-Stage Builds

To securely compile your application and package it into a minimal image, use Docker multi-stage builds. This allows you to use a heavy image with all the necessary compilers in the build stage, and then copy only the compiled binary to a minimal scratch or Distroless image in the final stage.

# Stage 1: Build Environment
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
# Statically compile the Go binary
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o myapp .

# Stage 2: Production Environment (Distroless)
FROM gcr.io/distroless/static-debian12
WORKDIR /
# Copy only the compiled binary from the builder stage
COPY --from=builder /app/myapp .
# Run as non-root (Distroless includes a nonroot user)
USER nonroot:nonroot
CMD ["/myapp"]

This approach guarantees that your production container contains absolutely nothing except your application code and the bare minimum dependencies required by the Linux kernel.

5. Continuous Verification: Image Vulnerability Scanning

Even if you configure your runtime environment perfectly, deploying an application with a known Common Vulnerability and Exposure (CVE) in its dependencies compromises the entire system. Because containers bundle their dependencies, they do not benefit from automatic host OS updates. If a vulnerability is discovered in OpenSSL and your container relies on an older Debian base image, your container remains vulnerable until you explicitly rebuild and redeploy it.

Integrating continuous image vulnerability scanning into your CI/CD pipeline is non-negotiable. Tools like Trivy, Grype, and Clair analyze your Docker image layers, inspecting installed OS packages and application dependencies (like npm packages, Python wheels, or Go modules) against centralized CVE databases.

Integrating Trivy in CI/CD

Trivy is widely regarded as one of the fastest and most accurate open-source scanners available. You should configure your pipeline to build the image, scan it with Trivy, and fail the build if any "CRITICAL" or "HIGH" vulnerabilities are detected.

# Example GitHub Actions snippet for Trivy
steps:
  - name: Build Docker image
    run: docker build -t my-app:${{ github.sha }} .

  - name: Run Trivy vulnerability scanner
    uses: aquasecurity/trivy-action@master
    with:
      image-ref: 'my-app:${{ github.sha }}'
      format: 'table'
      exit-code: '1'
      ignore-unfixed: true
      vuln-type: 'os,library'
      severity: 'CRITICAL,HIGH'

By failing the pipeline on high-severity vulnerabilities, you enforce a "shift-left" security posture, preventing vulnerable code from ever reaching your staging or production environments.

Advanced Hardening: Seccomp, AppArmor, and Resource Quotas

Once you have mastered the five core rules above, you can explore advanced kernel-level security configurations to build an impenetrable environment.

Seccomp Profiles

Secure Computing Mode (Seccomp) is a Linux kernel feature that allows you to filter which system calls a process can make. Docker applies a default seccomp profile that blocks about 44 out of 300+ available syscalls, preventing actions like rebooting the host or altering the system clock.

For high-security environments, you can create custom, strict seccomp profiles tailored specifically to your application's behavior. If your application only needs to read files and open network sockets, you can generate a seccomp profile that blocks absolutely everything else. If the application (or an attacker) attempts to call a blocked syscall, the kernel immediately terminates the process.

AppArmor / SELinux

AppArmor and SELinux provide Mandatory Access Control (MAC). While standard Linux permissions (Discretionary Access Control) govern who owns a file, MAC governs what a process is allowed to do, regardless of the user running it. Docker ships with a default AppArmor profile (docker-default) which is excellent, but for sensitive workloads, writing custom profiles to strictly define process boundaries offers an incredible layer of security.

Resource Limitations

Security isn't just about preventing unauthorized access; it is also about availability. If a container is compromised and utilized for cryptomining, or if a bug causes a memory leak, a single container can consume all CPU and RAM on the host system, starving other services and causing a Denial of Service (DoS).

Always enforce memory and CPU limits using Docker's cgroups integration. This ensures a rogue container is forcibly throttled or OOM-killed (Out of Memory) before it can destabilize the host.

services:
  backend:
    image: my-backend
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
        reservations:
          cpus: '0.1'
          memory: 256M

Network Security: Bridge Networks and Port Exposure

By default, Docker exposes ports to all interfaces on the host (0.0.0.0). If you map a database port using -p 5432:5432, it is exposed to the public internet unless restricted by a host firewall. This has led to countless data breaches where Redis, MongoDB, or PostgreSQL databases were accidentally left publicly accessible.

To mitigate network risks:

  • Never expose internal services to the host: Microservices and databases should only communicate over internal Docker bridge networks. They do not need ports published to the host machine.
  • Bind to localhost explicitly: If you must expose a port for local testing, bind it strictly to the loopback interface: ports: ["127.0.0.1:5432:5432"].
  • Use a Reverse Proxy: Only your API Gateway or Reverse Proxy (like Nginx, Traefik, or Caddy) should have ports mapped to the host (80 and 443). The proxy then routes traffic internally over the isolated Docker networks.

Conclusion

The era of "set it and forget it" container deployments is over. Treat every Docker container as a potential entry point into your broader infrastructure. By adopting a zero-trust mindset—enforcing non-root users, utilizing read-only filesystems, dropping all unnecessary capabilities, minimizing image sizes, and scanning aggressively—you build a defense-in-depth architecture capable of withstanding sophisticated modern attacks.

Remember that security is a continuous process. Regularly review your Dockerfile and docker-compose.yml configurations, stay updated on the latest CVEs, and leverage local tools like our Docker Compose Validator to ensure your configurations remain pristine and private.

Frequently Asked Questions

What are the essential Docker security best practices?
The core Docker security best practices include running containers as a non-root user, enforcing a read-only root filesystem, dropping unnecessary Linux capabilities (cap-drop=ALL), using minimal base images like Alpine or Distroless, and consistently scanning container images for known vulnerabilities.
How do I run Docker as a non-root user?
To run Docker as non-root, you should create a dedicated user and group within your Dockerfile using commands like 'addgroup' and 'adduser', and then specify the 'USER' directive before your application entrypoint. You can also enforce this at runtime using the 'user' key in docker-compose.yml or the '-u' flag in the docker run command.
Why should I use a read-only root filesystem in Docker?
Using a read-only root filesystem in Docker prevents attackers from writing malicious scripts, modifying application binaries, or downloading malware into the container. Any necessary write operations should be mapped to specific tmpfs mounts or external volumes, severely limiting an attacker's ability to establish persistence.
What does a docker capabilities drop achieve?
A docker capabilities drop removes specific Linux kernel privileges from the container. By default, Docker grants a subset of capabilities. Using '--cap-drop=ALL' strips all these privileges, preventing the container from altering network stacks, changing file ownership, or interacting deeply with the kernel, thereby mitigating container escape vulnerabilities.
How does a secure docker container prevent host escapes?
A secure docker container prevents host escapes through defense-in-depth: limiting user privileges (non-root), restricting kernel access (capabilities drop, seccomp profiles, AppArmor), and limiting filesystem modifications. Together, these layers ensure that even if a containerized application is compromised, the attacker cannot break out to control the underlying host server.
Is Alpine Linux secure enough for production containers?
Alpine Linux is highly regarded for production due to its minimal footprint, which significantly reduces the attack surface by excluding unnecessary packages and utilities (like curl or gcc). However, security is not automatic; you must still apply runtime hardening practices like running as non-root and dropping capabilities.
How can I manage volume permissions when running as non-root?
Managing volume permissions as a non-root user involves ensuring that the UID/GID running inside the container matches the owner of the directory on the host machine. You can set up the host directories with the correct ownership before mounting, or use tools to map users correctly. It requires careful configuration to avoid 'Permission denied' errors.