Skip to main content
COSMICBYTEZLABS
NewsSecurityHOWTOsToolsTraining
StudyProjectsNewsletterHire MeAbout
Subscribe

Press Enter to search or Esc to close

News
Security
HOWTOs
Tools
Training
Study
Projects
Newsletter
Hire Me
About
RSS Feed
Reading List
Subscribe

Stay in the Loop

Get the latest security alerts, tutorials, and tech insights delivered to your inbox.

Subscribe NowFree forever. No spam.
COSMICBYTEZLABS

Your trusted source for IT intelligence, cybersecurity insights, and hands-on technical guides.

1451+ Articles
151+ Guides

CONTENT

  • Latest News
  • Security Alerts
  • HOWTOs
  • Checklists
  • Projects
  • Exam Prep

RESOURCES

  • Search
  • Browse Tags
  • Newsletter Archive
  • Reading List
  • RSS Feed

COMPANY

  • About Us
  • Contact
  • Privacy Policy
  • Terms of Service

© 2026 CosmicBytez Labs. All rights reserved.

System Status: Operational
  1. Home
  2. HOWTOs
  3. Docker Security Hardening: Locking Down Container Environments
Docker Security Hardening: Locking Down Container Environments
HOWTOIntermediate

Docker Security Hardening: Locking Down Container Environments

A practical, step-by-step guide to hardening Docker deployments — from non-root users and read-only filesystems to capability drops, resource limits, and runtime scanning with Docker Bench Security.

Dylan H.

Tutorials

June 15, 2026
8 min read

Prerequisites

  • Basic Docker knowledge (images, containers, docker run)
  • Linux command-line familiarity
  • A Linux host with Docker Engine installed (20.10+)
  • sudo or root access on the Docker host

Introduction

Docker containers aren't secure by default. Out of the box, a container running as root with unrestricted capabilities and an open network poses significant risk — a container escape or compromised application can quickly become a host compromise. The good news: Docker exposes a rich set of security controls that, when applied systematically, dramatically shrink your attack surface.

This guide walks through the most impactful hardening steps in a logical order — from image hygiene and runtime flags to host-level controls and automated auditing with Docker Bench Security. Each step is independent; you can apply them incrementally to existing environments without a full rebuild.

Prerequisites

Before you begin, ensure the following:

  • Docker Engine 20.10 or later is installed and running on a Linux host
  • You have sudo or root access on the Docker host
  • The docker CLI is available in your PATH
  • (Optional) Docker Compose v2 if you manage multi-container stacks

Verify your Docker version:

docker version --format '{{.Server.Version}}'

Step 1 — Run Containers as Non-Root

The single highest-impact change: never run processes inside a container as UID 0. A process running as root inside a container is root on the host if a container escape occurs.

Option A: Specify a User in the Dockerfile

FROM debian:12-slim
 
# Create a dedicated app user
RUN groupadd --gid 1001 appgroup && \
    useradd --uid 1001 --gid appgroup --shell /bin/sh --create-home appuser
 
# Drop to non-root for all subsequent layers
USER appuser:appgroup
 
WORKDIR /app
COPY --chown=appuser:appgroup . .
 
ENTRYPOINT ["./entrypoint.sh"]

Option B: Override at Runtime

If you can't modify the image, pass --user at runtime:

docker run --user 1001:1001 nginx:alpine

Verify the Running UID

docker exec <container_name> id
# Expected: uid=1001(appuser) gid=1001(appgroup)

Step 2 — Drop Linux Capabilities

Docker grants containers a default set of Linux capabilities that most applications don't need. Stripping unnecessary capabilities limits what a compromised process can do.

Drop All, Add Only What's Required

# Drop all capabilities first, then add back only what the app needs
docker run \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  my-web-app:latest

Common capability reference:

CapabilityPurposeDrop if...
NET_RAWRaw socket accessNot running a packet capture tool
SYS_PTRACEProcess tracingNot debugging live processes
SYS_ADMINBroad sysadmin opsAlmost always — very dangerous
NET_BIND_SERVICEBind to ports < 1024Running on port ≥ 1024
CHOWNChange file ownershipApp doesn't need chown
SETUID / SETGIDChange process UIDsApp doesn't need privilege escalation

In Docker Compose

services:
  webapp:
    image: my-web-app:latest
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE

Step 3 — Enable read-only Root Filesystem

Mount the container's root filesystem as read-only. Applications that need to write data should do so only to explicitly defined volumes or tmpfs mounts.

docker run \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --mount type=volume,source=app-data,target=/var/lib/app \
  my-app:latest

The --tmpfs flag carves out in-memory writable space for temp files while keeping everything else immutable. The noexec and nosuid options on tmpfs prevent it from becoming an execution staging area.

Docker Compose equivalent

services:
  webapp:
    image: my-web-app:latest
    read_only: true
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64m
    volumes:
      - app-data:/var/lib/app

Step 4 — Prevent Privilege Escalation

The no-new-privileges flag prevents processes inside the container from gaining additional privileges via setuid or setgid binaries — even if the container is running as root.

docker run --security-opt no-new-privileges:true my-app:latest

This is a cheap, zero-downtime control that should be on by default for every container that doesn't explicitly need privilege escalation.

Step 5 — Enforce Resource Limits

An unrestrained container can exhaust CPU, memory, and file descriptors on the host — either through a runaway bug or a resource-exhaustion attack. Always set explicit limits.

docker run \
  --memory=512m \
  --memory-swap=512m \
  --cpus=1.0 \
  --pids-limit=100 \
  --ulimit nofile=1024:1024 \
  my-app:latest
FlagWhat it limits
--memoryRAM ceiling
--memory-swapRAM + swap (set equal to --memory to disable swap)
--cpusCPU core fraction (e.g. 0.5 = half a core)
--pids-limitMax processes/threads — limits fork bombs
--ulimit nofileOpen file descriptor limit

In Docker Compose

services:
  webapp:
    image: my-web-app:latest
    deploy:
      resources:
        limits:
          memory: 512m
          cpus: "1.0"
    pids_limit: 100
    ulimits:
      nofile:
        soft: 1024
        hard: 1024

Step 6 — Isolate Container Networks

By default, all containers on the default bridge network can communicate with each other. Create dedicated networks and restrict cross-container traffic to only what's necessary.

# Create isolated networks per service tier
docker network create --driver bridge frontend-net
docker network create --driver bridge backend-net
docker network create --driver bridge db-net
 
# Web app can reach backend, but not the DB directly
docker run --network frontend-net --name webapp my-web-app:latest
docker run --network backend-net --network frontend-net --name api my-api:latest
docker run --network db-net --name postgres postgres:16-alpine

Disable Inter-Container Communication on the Default Bridge

Edit /etc/docker/daemon.json:

{
  "icc": false,
  "iptables": true
}

Reload Docker after editing daemon.json:

sudo systemctl reload docker

Warning: Setting icc: false breaks any containers relying on direct inter-container communication on the default bridge. Test in a staging environment first and migrate to named networks before applying to production.

Step 7 — Harden Docker Images

Secure images are the foundation. Small, minimal images have fewer packages — and fewer CVEs.

Use Distroless or Slim Base Images

# Instead of ubuntu or debian full, use slim variants or distroless
FROM gcr.io/distroless/static-debian12:nonroot
 
# Or for Node.js
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
 
FROM gcr.io/distroless/nodejs22-debian12:nonroot
COPY --from=builder /app /app
WORKDIR /app
CMD ["server.js"]

Pin Exact Image Digests in Production

Tags are mutable — latest can change under you. Pin by digest:

# Pull with the digest pinned
docker pull nginx@sha256:67682bda769fae1ccf5183192b8daf37b64cae99c6c3302650f6f8bf5f0f95df

In a Dockerfile:

FROM nginx@sha256:67682bda769fae1ccf5183192b8daf37b64cae99c6c3302650f6f8bf5f0f95df

Scan Images for CVEs with Trivy

# Install Trivy
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
 
# Scan an image before deploying
trivy image --severity HIGH,CRITICAL my-app:latest

Fix HIGH and CRITICAL findings before pushing to production. Integrate scanning into your CI pipeline to catch vulnerabilities at build time.

Step 8 — Manage Secrets Properly

Never bake secrets into images or pass them via environment variables in plain text — they're visible in docker inspect output and process tables.

Use Docker Secrets (Swarm Mode)

# Create a secret from a file
echo "super-secret-db-password" | docker secret create db_password -
 
# Reference in a service
docker service create \
  --secret db_password \
  --name webapp \
  my-app:latest

Secrets are mounted at /run/secrets/<secret_name> inside the container — readable only by the process user, never in env vars.

For Compose (Non-Swarm) — Use an Env File with Restricted Permissions

# .env.production — chmod 600, never committed to git
chmod 600 .env.production
# docker-compose.yml
services:
  webapp:
    image: my-app:latest
    env_file: .env.production

For production workloads, integrate with HashiCorp Vault or a cloud secrets manager (AWS Secrets Manager, Azure Key Vault) and inject secrets at runtime via an init container or sidecar.

Step 9 — Enable AppArmor or seccomp Profiles

Apply a seccomp Profile

seccomp profiles filter which syscalls a container can make. Docker ships with a default seccomp profile that blocks ~44 dangerous syscalls. For tighter control, create a custom profile:

# Use the default Docker seccomp profile explicitly
docker run \
  --security-opt seccomp=/etc/docker/seccomp/default.json \
  my-app:latest
 
# Or disable only specific syscalls (advanced — requires testing)
# Download the default and customize it:
curl -o /tmp/seccomp-default.json \
  https://raw.githubusercontent.com/moby/moby/master/profiles/seccomp/default.json

Apply an AppArmor Profile

# Load a custom AppArmor profile
apparmor_parser -r -W /etc/apparmor.d/docker-my-app
 
# Apply it at runtime
docker run \
  --security-opt apparmor=docker-my-app \
  my-app:latest
 
# Verify the profile is active
docker exec <container_id> cat /proc/1/attr/current

Step 10 — Audit with Docker Bench Security

Docker Bench Security is an automated CIS Docker Benchmark checker. Run it against your host to get a comprehensive audit report.

docker run --rm \
  --net host \
  --pid host \
  --userns host \
  --cap-add audit_control \
  -e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
  -v /etc:/etc:ro \
  -v /lib/systemd/system:/lib/systemd/system:ro \
  -v /usr/bin/containerd:/usr/bin/containerd:ro \
  -v /usr/bin/runc:/usr/bin/runc:ro \
  -v /usr/lib/systemd:/usr/lib/systemd:ro \
  -v /var/lib:/var/lib:ro \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  --label docker_bench_security \
  docker/docker-bench-security

The output is organized by CIS benchmark section. Focus on [WARN] items first — [INFO] items are informational. Target a clean pass on sections 1 (Host Configuration), 2 (Docker Daemon Configuration), and 4 (Container Images).

Verification

After applying the hardening steps, verify your configuration:

# 1. Confirm container is running as non-root
docker exec <container_name> id
 
# 2. Check dropped capabilities
docker inspect <container_name> | jq '.[].HostConfig.CapDrop'
 
# 3. Verify read-only filesystem
docker inspect <container_name> | jq '.[].HostConfig.ReadonlyRootfs'
 
# 4. Check no-new-privileges is set
docker inspect <container_name> | jq '.[].HostConfig.SecurityOpt'
 
# 5. Verify memory limits
docker stats <container_name> --no-stream
 
# 6. Scan the running container's filesystem for CVEs
trivy image --input $(docker export <container_name> | trivy image --input -)
 
# 7. Run Docker Bench Security (see Step 10)

A hardened container should show:

  • uid=<non-zero> in the id output
  • ["ALL"] in CapDrop
  • true for ReadonlyRootfs
  • ["no-new-privileges:true"] in SecurityOpt

Troubleshooting

Container crashes with "permission denied" after adding --read-only

The application writes to a path that isn't covered by a tmpfs or volume mount. Identify which paths need to be writable:

# Run with strace to find write attempts (requires SYS_PTRACE cap temporarily)
docker run --cap-add SYS_PTRACE --read-only my-app:latest strace -e trace=write -p 1 2>&1 | grep "Permission denied"

Add a --tmpfs mount for each writable path that doesn't need persistence, or a named volume for paths that do.

App breaks after dropping capabilities

Start with all caps dropped and add them back one at a time. Use docker run --cap-drop=ALL --cap-add=<CAP> ... and check application logs after each addition.

Docker Bench reports "icc" warning even after setting daemon.json

Ensure Docker was fully restarted (not just reloaded) after editing daemon.json:

sudo systemctl restart docker
# Verify the config was picked up
docker info | grep -i "icc"

seccomp profile blocks a legitimate syscall

Check the container logs for EPERM errors and cross-reference the syscall number. Use strace to identify the blocked call, then add it to an allow-list in your custom seccomp JSON.

Secrets still visible in docker inspect

If you're using environment variables, migrate to Docker Secrets or a secrets manager. Environment variables set via --env or env_file are visible in inspect output — that's by design. There is no way to retroactively hide them short of migrating to the secrets API.

Summary

Docker security hardening is a layered discipline — no single control is sufficient on its own, but each layer you add meaningfully increases the cost and complexity of an attack.

The highest-ROI controls in order of impact:

  1. Non-root user — eliminates host UID 0 exposure from container escapes
  2. --cap-drop=ALL — removes Linux capability attack surface
  3. --read-only — prevents in-container persistence and lateral movement staging
  4. --security-opt no-new-privileges:true — blocks setuid/setgid abuse
  5. Network isolation — limits blast radius from a compromised container
  6. Resource limits — prevents DoS from runaway processes
  7. Image scanning — keeps CVE count low before deployment
  8. Docker Bench Security — continuously validates host and daemon posture

Apply these controls at image build time where possible (Dockerfile USER, distroless bases) and enforce the rest via a hardened docker run wrapper or a Compose template your team uses as a standard baseline. Integrate Trivy into CI to catch vulnerabilities before they reach production.

For teams running Kubernetes, the same principles apply — translate these controls to Pod Security Admission, SecurityContext, and Network Policies within your cluster.

#docker#containers#security-hardening#devops#linux#devsecops

Related Articles

Employee Offboarding: The Security Checklist Most Northern Alberta Businesses Skip

Offboarding is where most SMB security postures actually fail. The technical checklist is well-known. The process discipline is what&apos;s missing in…

8 min read

OT Security for Sawmills, Shops, and Ag Operations: The Part of Cyber That Breaks Production

OT — operational technology — is the side of cyber that takes a sawmill offline for a week. PLCs, telemetry, SCADA, building-management systems. Different…

8 min read

What a vCISO Actually Does for a 30-Person Business (and When You Don&apos;t Need One Yet)

vCISO services get marketed to every SMB with a security budget. Most businesses under 20 seats don&apos;t need one yet. Most businesses 20 to 100 seats with…

7 min read
Back to all HOWTOs