Key Takeaways
- Run as non-root: Add
USER 1001at the end of your Dockerfile. This single change prevents most container-to-host privilege escalation attacks. - Minimal base images:
python:3.12-slim(150MB) vspython:3.12(1.2GB). Fewer packages = fewer CVEs.distrolessandscratchimages reduce the attack surface to near-zero. - Trivy for CVE scanning:
trivy image myimage:tagin 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_SERVICEfor 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
- How to Install Docker on Ubuntu 24.04 LTS — Docker CE installation
- Docker Compose Tutorial 2026 — running secure compose stacks
- GitHub Actions CI/CD Tutorial — Trivy in CI pipelines
- Trivy Documentation — full CVE scanning reference
Tested on: Ubuntu 24.04 LTS (Hetzner CX22). Docker CE 27.x, Trivy 0.58.2. Last verified: April 17, 2026.