Container Security
- Container Security
A convenience boundary, not a security boundary
Containers are not a security boundary — they are a convenience boundary. A container running on the same kernel as its host shares that kernel. If you run a container as root, a container escape can give an attacker root on the host. Container security is about understanding what isolation containers actually provide, and then applying controls at every layer where that isolation is incomplete.
⚠️ The kernel is shared. Namespaces and cgroups partition resources, but every container calls into the same host kernel. A single kernel exploit — or a
--privilegedflag, or a bind-mounted host path — collapses the isolation entirely. Treat root-in-container as one misconfiguration away from root-on-host.
This page assumes you already know the Docker fundamentals — namespaces, cgroups, images, and Compose — covered on the Containerization page.
The Container Threat Model
What containers isolate:
| Isolation | Mechanism | Notes |
|---|---|---|
| Filesystem | Overlay FS + chroot-like | Host filesystem accessible if volume-mounted or --privileged |
| Process namespace | PID namespace | Container processes invisible to each other by default |
| Network namespace | Network namespace | Separate IP stack; bridge network by default |
| User namespace | UID namespace | UID 0 in container ≠ UID 0 on host only if userns-remap enabled |
What containers do not isolate:
- Kernel — all containers share the host kernel; a kernel exploit breaks all of them
- Kernel syscalls — by default a container can call almost any syscall the host allows
- Root user — a root process in a container has near-host-root privileges unless user namespace remapping is enabled
- Mounted volumes — whatever you bind-mount into a container is accessible to it
The practical threat model for the lab environment:
Kali (attacker)
↓ HTTP to port 80
nginx container → DVWA container → MariaDB container
↓
container escape?
↓
ubuntu-server host
↓
Proxmox host ← out of scope
A successful DVWA exploit that leads to RCE inside the container is one step from a container escape if the container is misconfigured. The controls in this page prevent that step.
Dockerfile Security
Run as a non-root user
The most impactful single change in a Dockerfile:
# Bad: process runs as root
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y nginx
# Good: create a dedicated user and switch to it
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y nginx \
&& useradd --system --no-create-home --shell /usr/sbin/nologin appuser
USER appuser
Verify after building:
docker run --rm myimage whoami # must not print "root"
Minimal base images
Every package in the base image is a potential CVE. Prefer:
| Base image | Use case | Approximate size |
|---|---|---|
alpine |
General; musl libc | ~7 MB |
debian:bookworm-slim |
Needs glibc | ~75 MB |
ubuntu:24.04 |
Familiarity; lab use | ~80 MB |
scratch |
Static binaries only | 0 MB |
Avoid latest tags in production — pin a specific digest:
FROM debian:bookworm-slim@sha256:abc123...
No secrets in image layers
Every RUN, ENV, and ARG instruction creates a layer that persists in the image history even if you delete the file in a later layer:
# Bad: password baked into layer forever
ENV DB_PASSWORD=supersecret
RUN curl -u admin:supersecret https://internal-api/setup
# Good: secret provided at runtime via environment or mounted file
# (OpenBao agent, Docker secrets, or explicit env var at run time)
RUN curl -u admin:${DB_PASSWORD} https://internal-api/setup
Inspect your image layers before pushing:
docker history --no-trunc myimage | grep -i secret
docker inspect myimage | jq '.[0].Config.Env'
Runtime Hardening
Read-only root filesystem
A read-only filesystem prevents an attacker from writing tools, persistence mechanisms, or modified binaries inside the container:
# docker-compose.yml
services:
dvwa:
image: dvwa:hardened
read_only: true
tmpfs:
- /tmp # writable scratch space
- /var/run # PID files
- /var/log # logs (or use a named volume)
volumes:
- dvwa-data:/var/www/html/hackable/uploads # the one writable path DVWA needs
Linux capabilities
Docker drops most capabilities by default but still grants more than most applications need. Drop all and add back only what the process requires:
services:
nginx:
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # bind to ports < 1024
- CHOWN # change file ownership at startup
- SETUID # drop to unprivileged user after startup
- SETGID
Common capabilities and when they are needed:
| Capability | What it allows | Usually needed? |
|---|---|---|
NET_BIND_SERVICE |
Bind to ports < 1024 | Only if serving HTTP/HTTPS directly |
NET_ADMIN |
Network configuration | Rarely; only network tools |
SYS_PTRACE |
Attach debugger | Never in production |
SYS_ADMIN |
Catch-all admin | Almost never; red flag |
CHOWN |
Change file ownership | Startup scripts that fix permissions |
SETUID / SETGID |
Change UID/GID | Startup scripts that drop privileges |
no-new-privileges
Prevents processes inside the container from gaining new capabilities via SUID binaries or setuid syscalls:
services:
dvwa:
security_opt:
- no-new-privileges:true
seccomp Profiles
seccomp (Secure Computing Mode) is a kernel feature that restricts which syscalls a process can make. Docker applies a default seccomp profile that blocks ~44 syscalls. You can tighten this further with a custom profile.
The default Docker profile blocks: keyctl, add_key, request_key, ptrace, mbind, migrate_pages, move_pages, set_mempolicy, and many others. Full list.
Custom profile — block ptrace and reboot
{
"defaultAction": "SCMP_ACT_ERRNO",
"syscalls": [
{
"names": ["ptrace", "reboot", "kexec_load"],
"action": "SCMP_ACT_ERRNO"
}
]
}
A practical approach: start from the Docker default profile and add blocks:
# Download the Docker default profile
curl -sO https://raw.githubusercontent.com/moby/moby/master/profiles/seccomp/default.json
# Apply it explicitly to a container
docker run --security-opt seccomp=default.json myimage
# Apply a custom profile
docker run --security-opt seccomp=custom-seccomp.json myimage
In Compose:
services:
dvwa:
security_opt:
- no-new-privileges:true
- seccomp:./custom-seccomp.json
Verifying seccomp is applied
docker inspect dvwa_container | jq '.[0].HostConfig.SecurityOpt'
# Should show something like:
# ["no-new-privileges:true","seccomp:{...}"]
AppArmor Profiles
AppArmor (Application Armor) is a Linux Security Module that restricts what a process can access based on a profile. Docker applies the docker-default AppArmor profile to all containers unless told otherwise.
Apply the default profile explicitly
services:
nginx:
security_opt:
- apparmor:docker-default
Generate a custom profile with aa-genprof
# Install AppArmor utilities
apt install apparmor-utils
# Generate a profile for nginx (runs nginx, watches what it does)
aa-genprof nginx
# Refine the profile by watching log violations
aa-logprof
# Check profile status
aa-status
Profile modes
| Mode | Behavior |
|---|---|
enforce |
Block violations; log them |
complain |
Allow violations; log them |
disable |
No AppArmor enforcement |
Always develop profiles in complain mode first, then switch to enforce after testing:
aa-complain /etc/apparmor.d/docker-nginx
# ... test the application ...
aa-enforce /etc/apparmor.d/docker-nginx
Image Scanning with Trivy
Trivy scans container images, filesystems, and IaC for known CVEs, misconfigurations, and secrets.
Basic image scan
# Scan an image — show only HIGH and CRITICAL
trivy image --severity HIGH,CRITICAL dvwa:latest
# Exit non-zero if any CRITICAL CVEs found (useful in CI)
trivy image --severity CRITICAL --exit-code 1 dvwa:latest
# Save report as JSON
trivy image --format json --output trivy-report.json dvwa:latest
Interpreting output
dvwa:latest (debian 12.5)
=========================
Total: 12 (HIGH: 8, CRITICAL: 4)
┌──────────────┬────────────────┬──────────┬────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Fixed Version │
├──────────────┼────────────────┼──────────┼────────────────────────────────┤
│ libssl3 │ CVE-2024-XXXX │ CRITICAL │ 3.0.11-1~deb12u2 │
│ libcurl4 │ CVE-2024-YYYY │ HIGH │ 7.88.1-10+deb12u6 │
└──────────────┴────────────────┴──────────┴────────────────────────────────┘
Remediation: rebuild with an updated base image or upgrade the specific package in your Dockerfile.
Filesystem scan (SCA for application dependencies)
# Scan a Python project's requirements
trivy fs --scanners vuln .
# Scan with secret detection
trivy fs --scanners secret .
Generating an SBOM
A Software Bill of Materials lists every component in an image — required by many compliance frameworks and useful for tracking which images are affected when a new CVE drops:
# Generate CycloneDX SBOM
trivy sbom --format cyclonedx --output sbom.json dvwa:hardened
# Generate SPDX SBOM
trivy sbom --format spdx-json --output sbom.spdx.json dvwa:hardened
Network Segmentation
By default, all containers in a Compose file share one bridge network and can reach each other freely. Segment the network to enforce the principle of least privilege at the network layer:
networks:
frontend: # nginx only
driver: bridge
backend: # dvwa + mariadb only
driver: bridge
services:
nginx:
networks:
- frontend
- backend # nginx bridges both to proxy to dvwa
dvwa:
networks:
- backend # dvwa cannot be reached from outside backend
mariadb:
networks:
- backend # mariadb only accessible from backend
Verify isolation:
# This should fail — nginx should NOT reach mariadb directly
docker exec nginx_container mariadb -h mariadb -u dvwa
Secrets in Containers
Never put secrets in environment variables visible to docker inspect, or in image layers.
Option 1: Docker secrets (Compose)
secrets:
db_password:
file: ./secrets/db_password.txt
services:
dvwa:
secrets:
- db_password
# Secret available at /run/secrets/db_password inside container
Option 2: OpenBao Agent sidecar
Run an OpenBao Agent container that authenticates to OpenBao and writes a secret file to a shared volume:
services:
openbao-agent:
image: openbao/openbao:latest
command: bao agent -config=/openbao/config/agent.hcl
volumes:
- bao-secrets:/openbao/secrets
dvwa:
volumes:
- bao-secrets:/run/secrets:ro
# DVWA reads /run/secrets/db_password at startup
volumes:
bao-secrets:
Container Security Checklist
Before a container goes into a lab or production environment:
scripts/container-check.sh
#!/usr/bin/env bash
# scripts/container-check.sh
set -euo pipefail
CONTAINER="$1"
FAIL=0
check() {
local label="$1" result="$2"
if [[ "$result" == "pass" ]]; then
echo " PASS $label"
else
echo " FAIL $label"
((FAIL++))
fi
}
# Not running as root
user=$(docker exec "$CONTAINER" whoami 2>/dev/null)
[[ "$user" != "root" ]] && r=pass || r=fail
check "Non-root user (got: $user)" "$r"
# no-new-privileges set
val=$(docker inspect "$CONTAINER" | jq -r '.[0].HostConfig.SecurityOpt[]?' | grep -c no-new-privileges || true)
[[ "$val" -ge 1 ]] && r=pass || r=fail
check "no-new-privileges" "$r"
# Not privileged
priv=$(docker inspect "$CONTAINER" | jq -r '.[0].HostConfig.Privileged')
[[ "$priv" == "false" ]] && r=pass || r=fail
check "Not --privileged" "$r"
# No secrets in env
envvars=$(docker inspect "$CONTAINER" | jq -r '.[0].Config.Env[]?' | grep -iE 'password|secret|key|token' || true)
[[ -z "$envvars" ]] && r=pass || r=fail
check "No secrets in environment variables" "$r"
echo ""
[[ "$FAIL" -eq 0 ]] && echo "All checks passed." || echo "$FAIL check(s) failed."
exit "$FAIL"
Key takeaways
- Containers share the host kernel — isolation is real but incomplete, so apply controls at every layer rather than trusting the boundary.
- The single highest-impact change is dropping out of root: a non-root
USERin the Dockerfile plusno-new-privilegesblocks most escalation paths. - Harden the runtime — read-only root filesystem,
cap_drop: ALLwith a minimalcap_add, a seccomp profile, and thedocker-defaultAppArmor profile. - Scan images with Trivy in CI (
--exit-code 1on CRITICAL) and generate an SBOM so you can tell which images a new CVE affects. - Segment Compose networks and keep secrets out of image layers and environment variables — use Docker secrets or an OpenBao Agent sidecar instead.
References
- Docker Security Documentation — https://docs.docker.com/engine/security/
- OWASP Docker Security Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html
- Trivy Documentation — https://aquasecurity.github.io/trivy/
- Linux Capabilities — capabilities(7) — https://man7.org/linux/man-pages/man7/capabilities.7.html
- seccomp security profiles for Docker — https://docs.docker.com/engine/security/seccomp/
- AppArmor for Docker — https://docs.docker.com/engine/security/apparmor/
- NIST SP 800-190 — Application Container Security Guide — https://csrc.nist.gov/publications/detail/sp/800/190/final
Related course pages: Containerization · CI/CD & DevOps · Secrets management with OpenBao
🛠️ Maintenance note: Trivy’s CLI moved scanner selection to
--scanners vuln,secret,misconfig(the older--security-checksflag is removed); re-check flag names against the installed Trivy version. The Docker default seccomp profile and capability set shift between Engine releases — pullprofiles/seccomp/default.jsonfrom the matchingmoby/mobytag rather thanmaster. this course uses OpenBao (the OpenBao Project / Linux Foundation fork of HashiCorp Vault, after Vault’s 2023 BSL relicensing); the Agent sidecar above uses theopenbao/openbaoimage andbaoCLI, and the API stays Vault-compatible.