| name | docker-container-security |
| description | Run containers with a defensive baseline that survives production. Covers non-root users, read-only filesystems, dropped Linux capabilities, secret mounts instead of build-time bake-in, image scanning with trivy, distroless and minimal base images, and the Docker-bypasses-UFW firewall pitfall. Invoke when adding Docker to a VPS with UFW, writing a new Dockerfile, or pushing an image to a public registry. |
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.yml for 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):
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
sudo ufw-docker allow web 80/tcp
Option B — bind to localhost when you front with a reverse proxy:
services:
app:
ports:
- "127.0.0.1:9000:9000"
Verify:
sudo ss -tlnp | grep docker
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:
-slim is 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
--chown on COPY so files are not owned by root inside the image
USER last 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 \
--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 explicit tmpfs mounts 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 via setuid binaries.
--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.
docker build --build-arg STRIPE_KEY=sk_live_... .
docker run -e STRIPE_KEY="$STRIPE_KEY" myapp
docker run -v /run/secrets/stripe:/run/secrets/stripe:ro myapp
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
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.
networks:
frontend:
backend:
internal: true
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
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
docker ps --format '{{.Names}}\t{{.Ports}}' | grep -E '0\.0\.0\.0|:::' || echo "OK - none"
docker images --format '{{.Repository}}:{{.Tag}} {{.CreatedSince}}' | grep -E '(months|year)'
docker container prune -f --filter 'until=720h'
docker image prune -f
Compose-file checklist
Before deploying any docker-compose.yml to production, walk this:
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)