Docker / Container Security
A pragmatic baseline for Docker on a single VPS or a small cluster. Covers the Dockerfile, the run-time configuration, and the host-side gotchas — particularly the UFW-bypass that catches most people once.
When to invoke
- Installing Docker on a VPS that has UFW (read the UFW section first — Docker bypasses UFW by default)
- Writing a new Dockerfile or
docker-compose.ymlfor production - Pushing an image to a public registry
- Periodic audit of running containers
- After a base-image CVE that affects your stack
The UFW bypass — read this first
Docker manipulates iptables directly. By default, ports published with -p are exposed to the world, even if UFW says they should not be. ufw status will mislead you.
Two options:
Option A — use ufw-docker (community-maintained, robust):
# Install ufw-docker
sudo wget -O /usr/local/bin/ufw-docker \
https://github.com/chaifeng/ufw-docker/raw/master/ufw-docker
sudo chmod +x /usr/local/bin/ufw-docker
sudo ufw-docker install
sudo systemctl restart ufw
# Then allow per-container:
sudo ufw-docker allow web 80/tcp
Option B — bind to localhost when you front with a reverse proxy:
# docker-compose.yml — bind to 127.0.0.1, not 0.0.0.0
services:
app:
ports:
- "127.0.0.1:9000:9000" # host nginx proxies to this
Verify:
sudo ss -tlnp | grep docker # should not show 0.0.0.0:<port> for internal services
Dockerfile baseline
Every production Dockerfile should:
- Run as a non-root user
- Pin the base image to a specific digest or version tag (not
latest) - Drop build tools from the final image
- Not contain secrets baked in
# Pin to specific Node version; consider digest pinning for stricter supply chain
FROM node:20.11.1-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20.11.1-bookworm-slim
WORKDIR /app
# Create non-root user
RUN groupadd -r app && useradd -r -g app -d /app -s /sbin/nologin app
COPY --from=deps /app/node_modules ./node_modules
COPY --chown=app:app . .
USER app
EXPOSE 3000
# Use exec form so the process becomes PID 1 and receives signals
CMD ["node", "server.js"]
Notes:
-slimis much smaller than the default tag. Distroless (gcr.io/distroless/nodejs20-debian12) is smaller and shell-less — harder to live in if an attacker gets RCE- Multi-stage to leave compilers / dev deps out of the runtime image
--chownonCOPYso files are not owned by root inside the imageUSERlast so all later instructions run as that user
Run-time hardening
For each container, prefer the most restrictive run-time options the app actually tolerates.
docker run -d \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \ # only if the app needs to bind <1024
--security-opt no-new-privileges:true \
--pids-limit 200 \
--memory 512m \
--cpus 1 \
-p 127.0.0.1:3000:3000 \
--name myapp myapp:1.2.3
In docker-compose.yml:
services:
app:
image: myapp:1.2.3
read_only: true
tmpfs:
- /tmp:size=64m,noexec,nosuid
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
security_opt:
- no-new-privileges:true
pids_limit: 200
mem_limit: 512m
cpus: 1.0
user: "1000:1000"
restart: unless-stopped
What each flag does:
--read-only— root filesystem cannot be modified. Writable dirs are explicittmpfsmounts or volumes.--cap-drop=ALL+ selective--cap-add— drop all Linux capabilities, add only what the app needs. Most apps need none.--security-opt no-new-privileges— process cannot gain privileges viasetuidbinaries.--pids-limit— caps fork-bomb DoS.--memory/--cpus— prevents one runaway container from killing the host.
Secrets — never bake them in
Anything in ENV or ARG ends up in image layers. Anyone who can pull the image can extract them.
# Bad
docker build --build-arg STRIPE_KEY=sk_live_... .
# Good — at runtime, from env
docker run -e STRIPE_KEY="$STRIPE_KEY" myapp
# Better — Docker secrets (Swarm) or mounted files
docker run -v /run/secrets/stripe:/run/secrets/stripe:ro myapp
# app reads /run/secrets/stripe
# Best — a real secret manager (Vault, cloud KMS) with short-lived tokens
To inspect a built image for accidental secrets:
docker history --no-trunc myapp:1.2.3
docker save myapp:1.2.3 -o myapp.tar
tar -xf myapp.tar
# search the layer tarballs for .env / known token prefixes
Image scanning
Run a scanner on every image before deploy. Trivy is the most common choice and is fast enough for CI.
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:1.2.3
In GitHub Actions:
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan with Trivy
uses: aquasecurity/trivy-action@<pinned-sha>
with:
image-ref: 'myapp:${{ github.sha }}'
severity: 'HIGH,CRITICAL'
exit-code: '1'
Scan on a schedule too — vulnerabilities are discovered after images are built. A weekly re-scan catches newly-known CVEs in already-deployed images.
Network segmentation
Default Docker bridge network puts all containers on one flat L2 segment. They can reach each other freely.
# docker-compose.yml — isolate concerns
networks:
frontend:
backend:
internal: true # no external connectivity
database:
internal: true
services:
web:
networks: [frontend, backend]
api:
networks: [backend, database]
db:
networks: [database]
internal: true networks have no NAT to the outside — only useful for intra-cluster communication. The DB cannot connect out to the internet, which limits exfil if compromised.
Periodic audit
# What is running with --privileged or as root?
docker ps --format 'table {{.Names}}\t{{.Image}}' | tail -n +2 | while read name img; do
user=$(docker inspect --format '{{.Config.User}}' "$name")
priv=$(docker inspect --format '{{.HostConfig.Privileged}}' "$name")
echo "$name image=$img user='${user:-<root>}' privileged=$priv"
done
# Containers with publicly-bound ports
docker ps --format '{{.Names}}\t{{.Ports}}' | grep -E '0\.0\.0\.0|:::' || echo "OK - none"
# Images older than 90 days
docker images --format '{{.Repository}}:{{.Tag}} {{.CreatedSince}}' | grep -E '(months|year)'
# Stopped containers + dangling images — clean up unless you need forensic state
docker container prune -f --filter 'until=720h' # 30 days
docker image prune -f
Compose-file checklist
Before deploying any docker-compose.yml to production, walk this:
- No
latesttags; versions are pinned (and ideally digest-pinned for critical images) - Every service has
user:set to a non-root UID:GID -
read_only: truewherever the app tolerates it (most do) -
cap_drop: [ALL]+ minimalcap_add -
security_opt: [no-new-privileges:true] -
mem_limitandpids_limitset - Ports bound to
127.0.0.1unless intentionally public - No secrets in
environment:— useenv_file:(gitignored), Docker secrets, or a manager - Internal networks (
internal: true) used to keep DB/cache off the public bridge -
restart: unless-stoppedrather thanalways(so manual stops stick) - Volumes are named, not anonymous (named volumes survive
docker compose down, anonymous do not)
What this skill will not do
- Help bypass container isolation on systems you do not own
- Recommend
--privileged,--cap-add=SYS_ADMIN, or running as root unless there is a documented, narrow need - Replace a full Kubernetes security review (different stack, different controls)