Vucense

Docker Security Best Practices 2026: Harden Containers on Ubuntu 24.04

🟡Intermediate

Harden Docker containers on Ubuntu 24.04 LTS. Non-root users, read-only filesystems, resource limits, image scanning with Trivy, secrets management, network isolation, and security benchmarks.

Sarah Jenkins

Author

Sarah Jenkins

Open-Source Community & Ecosystem Lead

Published

Duration

Reading

17 min

Build

25 min

Docker Security Best Practices 2026: Harden Containers on Ubuntu 24.04
Article Roadmap

Key Takeaways

  • Run as non-root: Add USER 1001 at the end of your Dockerfile. This single change prevents most container-to-host privilege escalation attacks.
  • Minimal base images: python:3.12-slim (150MB) vs python:3.12 (1.2GB). Fewer packages = fewer CVEs. distroless and scratch images reduce the attack surface to near-zero.
  • Trivy for CVE scanning: trivy image myimage:tag in 30 seconds shows all known CVEs. Integrate into CI pipelines to block deploys with CRITICAL vulnerabilities.
  • Capabilities: By default, Docker containers get 14 Linux capabilities. Drop all: --cap-drop ALL, then add only what you need: --cap-add NET_BIND_SERVICE for binding to ports < 1024.

Introduction: Container Security in 2026

Direct Answer: How do I secure Docker containers on Ubuntu 24.04 LTS in 2026?

To secure Docker containers on Ubuntu 24.04, apply these five controls in every Dockerfile and docker run command: (1) Add RUN useradd -m -u 1001 appuser && USER appuser to run as non-root. (2) Use --read-only flag or read_only: true in compose.yaml to prevent filesystem writes (mount tmpfs for necessary write paths). (3) Set --cap-drop ALL --cap-add NET_BIND_SERVICE to drop all capabilities except what’s needed. (4) Set --memory 512m --cpus 1.0 resource limits. (5) Run trivy image yourimage:latest to scan for CVEs before deployment. Additionally: never hardcode secrets in environment variables — use Docker secrets or mounted files. Use --no-new-privileges to prevent privilege escalation inside the container. Pin image tags (nginx:1.24.0 not nginx:latest) for reproducible builds.

“A container is only as secure as its image, its runtime configuration, and its secrets management. Three surfaces — and most breaches exploit the one that received the least attention.”


Part 1: Secure Dockerfile Patterns

The hardened Dockerfile template

# Hardened Dockerfile — Vucense sovereign standard 2026
# Apply to: Python, Node.js, Go, or any application

# ── Stage 1: Build ────────────────────────────────────────
FROM python:3.12-slim AS builder

WORKDIR /build

# Install build dependencies (NOT in final image)
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential gcc \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# ── Stage 2: Runtime ──────────────────────────────────────
FROM python:3.12-slim AS runtime

# Security: update base image packages immediately
RUN apt-get update && apt-get upgrade -y && \
    apt-get install -y --no-install-recommends curl && \
    rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Copy only what we need from the builder
COPY --from=builder /install /usr/local
COPY --chown=1001:1001 app/ ./app/

# Security: create non-root user
RUN groupadd -g 1001 appgroup && \
    useradd -u 1001 -g appgroup -m -s /bin/bash appuser && \
    chown -R appuser:appgroup /app

# Security: switch to non-root user BEFORE any further commands
USER 1001

# Security: document exposed ports (doesn't publish them — just documents)
EXPOSE 8000

# Security: use exec form (not shell form) to avoid sh -c wrapper
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Test the Dockerfile:

# Build the image
docker build -t myapp:secure .

# Verify it runs as non-root
docker run --rm myapp:secure id

Expected output:

uid=1001(appuser) gid=1001(appgroup) groups=1001(appgroup)
# Check the image size (slim vs full)
docker images myapp:secure --format "{{.Size}}"

Expected output:

198MB

vs ~1.2GB for the full python:3.12 base image.


Part 2: Runtime Security Controls

# The most secure docker run command
docker run \
  --name myapp \
  --user 1001:1001 \           # Run as non-root UID:GID
  --read-only \                # Read-only root filesystem
  --tmpfs /tmp:rw,noexec,nosuid,size=100m \  # Writable tmpfs for /tmp
  --cap-drop ALL \             # Drop ALL Linux capabilities
  --cap-add NET_BIND_SERVICE \ # Add back only what's needed
  --no-new-privileges \        # Prevent privilege escalation
  --memory 512m \              # Hard memory limit
  --memory-reservation 256m \  # Soft memory limit (for scheduling)
  --cpus 1.0 \                 # CPU limit (1 full core)
  --pids-limit 100 \           # Limit process count (prevent fork bombs)
  --security-opt no-new-privileges:true \
  --security-opt seccomp:default \   # Apply default seccomp profile
  -p 127.0.0.1:8000:8000 \    # Bind to localhost only
  --restart unless-stopped \
  myapp:secure

The same controls in compose.yaml:

services:
  api:
    image: myapp:secure
    user: "1001:1001"
    read_only: true
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=100m
      - /var/run:rw,noexec,nosuid
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    security_opt:
      - no-new-privileges:true
      - seccomp:default
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: '1.0'
        reservations:
          memory: 256M
    pids_limit: 100
    ports:
      - "127.0.0.1:8000:8000"
    restart: unless-stopped

Part 3: Image Scanning with Trivy

Trivy scans Docker images for known CVEs (Common Vulnerabilities and Exposures) in OS packages and application dependencies.

# Install Trivy (Aqua Security)
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | \
  sudo sh -s -- -b /usr/local/bin latest

trivy --version

Expected output:

Version: 0.58.2
# Scan an image for vulnerabilities
trivy image nginx:alpine

Expected output:

nginx:alpine (alpine 3.19.0)

Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
# Scan your application image
trivy image myapp:secure

# Scan and fail if CRITICAL CVEs found (for CI pipelines)
trivy image --exit-code 1 --severity CRITICAL myapp:secure

Expected output (clean image):

myapp:secure (debian 12.9)

Total: 2 (HIGH: 1, MEDIUM: 1, CRITICAL: 0)

┌─────────────────────┬───────────────┬──────────┬──────────┬────────────────────┐
│       Library       │ Vulnerability │ Severity │  Status  │ Installed Version  │
├─────────────────────┼───────────────┼──────────┼──────────┼────────────────────┤
│ libc6               │ CVE-2024-xxxx │ MEDIUM   │ fixed    │ 2.36-9             │
│ ...                 │ ...           │ HIGH     │ will_fix │ ...                │
└─────────────────────┴───────────────┴──────────┴──────────┴────────────────────┘
# Scan only for CRITICAL — used as CI gate
trivy image --severity CRITICAL --exit-code 1 myapp:secure
echo "Exit code: $?"

Expected output (no CRITICAL):

(no output)
Exit code: 0

Integrate Trivy into GitHub Actions:

# .github/workflows/security-scan.yml
- name: Scan Docker image with Trivy
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: ${{ secrets.DOCKER_USERNAME }}/myapp:latest
    format: table
    exit-code: 1
    severity: CRITICAL,HIGH
    ignore-unfixed: true   # Don't fail on unfixed CVEs

Part 4: Secrets Management

# ✗ WRONG: Secret in environment variable (visible in docker inspect)
docker run -e DB_PASSWORD=mysecret myapp

# ✓ RIGHT 1: Docker secret (Swarm mode)
echo "mysecret" | docker secret create db_password -
docker service create \
  --secret db_password \
  --env DB_PASSWORD_FILE=/run/secrets/db_password \
  myapp

# ✓ RIGHT 2: Mount secret as a file (single-node)
# Create secret file with correct permissions
echo "mysecret" | sudo install -m 600 -o 1001 /dev/stdin /run/secrets/db_password

docker run \
  -v /run/secrets/db_password:/run/secrets/db_password:ro \
  myapp

# Read secret in application code:
# with open("/run/secrets/db_password") as f:
#     password = f.read().strip()

Verify secrets aren’t visible in environment:

docker inspect myapp 2>/dev/null | \
  python3 -c "import json,sys; d=json.load(sys.stdin); \
  env=[e for e in d[0]['Config']['Env'] if 'PASSWORD' in e or 'SECRET' in e]; \
  print('Secrets in env:', env if env else 'None (correct)')"

Expected output:

Secrets in env: None (correct)

Part 5: Docker Daemon Security

# Create or update Docker daemon configuration
sudo tee /etc/docker/daemon.json << 'EOF'
{
  "userns-remap": "default",
  "no-new-privileges": true,
  "live-restore": true,
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  },
  "default-ulimits": {
    "nofile": {
      "Name": "nofile",
      "Hard": 64000,
      "Soft": 64000
    }
  }
}
EOF

sudo systemctl restart docker

# Verify user namespace remapping is active
docker info | grep "Security Options\|userns"

Expected output:

 Security Options:
  seccomp
   Profile: builtin
  userns

Part 6: Network Isolation

# Create isolated networks per application tier
docker network create --driver bridge \
  --opt com.docker.network.bridge.enable_icc=false \  # Disable inter-container comms
  app-frontend

docker network create --driver bridge app-backend

# Connect services to only the networks they need
# Web → frontend only
# API → frontend + backend
# DB → backend only
# compose.yaml — network isolation
services:
  web:
    networks:
      - frontend

  api:
    networks:
      - frontend
      - backend

  db:
    networks:
      - backend    # DB not accessible from web directly

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true    # No external access to backend network

Part 7: The Security Audit Script

cat > ~/scripts/docker-security-audit.sh << 'EOF'
#!/bin/bash
# Docker Security Audit — checks containers against best practices
set -euo pipefail

PASS=0; WARN=0; FAIL=0
ok()   { echo "  ✓ $*"; ((PASS++)); }
warn() { echo "  ⚠ $*"; ((WARN++)); }
fail() { echo "  ✗ $*"; ((FAIL++)); }

echo "=== DOCKER SECURITY AUDIT ==="
echo ""

echo "[ Docker daemon security ]"
docker info 2>/dev/null | grep -q "userns" && \
  ok "User namespace remapping active" || warn "User namespace remapping not active"

docker info 2>/dev/null | grep -q "seccomp" && \
  ok "seccomp filtering active" || fail "seccomp not active"

echo ""
echo "[ Container inspection ]"
for cid in $(docker ps -q 2>/dev/null); do
    name=$(docker inspect --format '{{.Name}}' "$cid" | tr -d '/')
    
    # Check non-root
    user=$(docker inspect --format '{{.Config.User}}' "$cid")
    if [[ -z "$user" || "$user" == "root" || "$user" == "0" ]]; then
        fail "$name: running as root (add USER in Dockerfile)"
    else
        ok "$name: running as user '$user'"
    fi
    
    # Check read-only filesystem
    readonly=$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' "$cid")
    if [[ "$readonly" == "true" ]]; then
        ok "$name: read-only filesystem"
    else
        warn "$name: writable root filesystem"
    fi
    
    # Check memory limit
    mem=$(docker inspect --format '{{.HostConfig.Memory}}' "$cid")
    if [[ "$mem" -gt 0 ]]; then
        ok "$name: memory limit set ($(( mem / 1024 / 1024 ))MB)"
    else
        warn "$name: no memory limit — could consume all host RAM"
    fi
    
    # Check privileged
    priv=$(docker inspect --format '{{.HostConfig.Privileged}}' "$cid")
    if [[ "$priv" == "true" ]]; then
        fail "$name: running PRIVILEGED (maximum security risk)"
    else
        ok "$name: not privileged"
    fi
    
    # Check exposed to 0.0.0.0
    ports=$(docker inspect --format '{{json .HostConfig.PortBindings}}' "$cid" 2>/dev/null)
    if echo "$ports" | grep -q '"HostIp":""'; then
        warn "$name: port exposed on 0.0.0.0 (consider 127.0.0.1)"
    fi
done

echo ""
echo "─────────────────────────────────────────────────"
echo "Result: $PASS passed, $WARN warnings, $FAIL critical"

(( FAIL > 0 )) && exit 2
(( WARN > 0 )) && exit 1
exit 0
EOF

chmod +x ~/scripts/docker-security-audit.sh
~/scripts/docker-security-audit.sh

Expected output (hardened containers):

=== DOCKER SECURITY AUDIT ===

[ Docker daemon security ]
  ✓ User namespace remapping active
  ✓ seccomp filtering active

[ Container inspection ]
  ✓ demo-api: running as user '1001'
  ✓ demo-api: read-only filesystem
  ✓ demo-api: memory limit set (512MB)
  ✓ demo-api: not privileged
  ✓ demo-db: running as user '999'
  ✓ demo-db: memory limit set (256MB)
  ✓ demo-db: not privileged

─────────────────────────────────────────────────
Result: 7 passed, 0 warnings, 0 critical

Troubleshooting

permission denied after adding --read-only

Cause: Application writes to a path that’s now read-only. Diagnosis:

docker run --rm --read-only myapp 2>&1 | grep "read-only"

Fix: Add --tmpfs /path/that/needs/writes:rw,noexec,nosuid for each writable path your app needs.

operation not permitted after --cap-drop ALL

Cause: App needs a capability that was dropped. Diagnosis:

# Run with strace to see what capability is denied
docker run --rm --cap-drop ALL myapp 2>&1 | grep "Operation not permitted"

Fix: Add back the specific capability: --cap-add NET_BIND_SERVICE, --cap-add DAC_OVERRIDE, etc.


Quick Reference

# Image scanning
trivy image myimage:tag                    # Scan for CVEs
trivy image --severity CRITICAL myimage   # Show only critical
trivy image --exit-code 1 --severity CRITICAL myimage  # Fail if critical found

# Runtime flags
--user 1001:1001                           # Run as non-root
--read-only                                # Read-only filesystem
--tmpfs /tmp:rw,noexec,nosuid,size=100m   # Writable tmpfs
--cap-drop ALL                            # Drop all capabilities
--cap-add NET_BIND_SERVICE               # Add back specific capability
--no-new-privileges                       # No privilege escalation
--memory 512m --cpus 1.0                 # Resource limits
--pids-limit 100                          # Limit processes

# Audit
docker inspect CONTAINER                   # Full container config
docker history IMAGE                       # Image layer history
docker diff CONTAINER                      # Changes from base image

Conclusion

Docker security is a defense-in-depth practice: non-root users prevent privilege escalation, read-only filesystems prevent tampering, capability dropping reduces the attack surface, Trivy scanning catches known CVEs before deployment, and the security audit script provides ongoing visibility. Applied together, these controls make container-based deployments significantly harder to exploit even if a vulnerability exists in application code.


People Also Ask

Is Docker rootless mode secure enough for production?

Docker rootless mode (running the Docker daemon as a non-root user) provides strong security for most production workloads. The daemon process cannot affect host files owned by root. Combined with the hardening controls in this guide (non-root containers, capability dropping, seccomp), rootless mode achieves near-VM-level isolation. Install: dockerd-rootless-setuptool.sh install. The main limitation is reduced performance for some network modes and incompatibility with features that require host networking.

What is the difference between --cap-drop ALL and --privileged?

They’re opposites. --cap-drop ALL removes all Linux capabilities from the container, minimising what processes inside can do. --privileged grants full access to all host devices and capabilities — it’s functionally equivalent to running as root on the host. Never use --privileged in production. If you find yourself needing --privileged, the almost always correct answer is to identify the specific capability required (--cap-add CAP_NAME) and add only that.

How do I scan a private registry image with Trivy?

# Login to registry first
docker login registry.yourdomain.com

# Trivy uses Docker's credential store
trivy image registry.yourdomain.com/myapp:1.0.0

For CI pipelines with private registries, set TRIVY_USERNAME and TRIVY_PASSWORD environment variables.


Further Reading


Tested on: Ubuntu 24.04 LTS (Hetzner CX22). Docker CE 27.x, Trivy 0.58.2. Last verified: April 17, 2026.

Further Reading

All Dev Corner

Comments