In the previous tutorial, you learned about security logging and monitoring. Now let us secure where your code runs. Docker containers are everywhere, but the default configuration is not secure enough for production. In this article, you will learn how to harden Docker containers, scan images for vulnerabilities, and manage secrets safely.
Why Container Security Matters
Containers provide isolation, but they are not virtual machines. By default:
- Containers run as root — if an attacker breaks out, they have root on the host
- Docker images contain hundreds of packages, many with known vulnerabilities
- Secrets are often baked into images or passed as environment variables (visible in process lists)
- Network ports are exposed by default — more attack surface
A compromised container can lead to:
- Data theft from other containers on the same host
- Lateral movement across your infrastructure
- Cryptocurrency mining on your servers
- Access to secrets and API keys
Rule 1: Never Run as Root
The most important Docker security rule. By default, containers run as root (UID 0). If an attacker gains code execution inside the container, they have root privileges.
Create a Non-Root User
Dockerfile:
FROM node:20-alpine
# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Set working directory
WORKDIR /app
# Copy files and install dependencies
COPY package*.json ./
RUN npm ci --only=production
COPY . .
# Change ownership to non-root user
RUN chown -R appuser:appgroup /app
# Switch to non-root user
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
Go Dockerfile:
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .
# Runtime: minimal image with non-root user
FROM scratch
COPY --from=builder /app/server /server
# Use numeric UID (scratch has no user database)
USER 65534
EXPOSE 8080
ENTRYPOINT ["/server"]
Python Dockerfile:
FROM python:3.12-slim
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 8000
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000"]
Rule 2: Use Minimal Base Images
Smaller images have fewer packages, which means fewer vulnerabilities.
| Base Image | Size | Packages | Use Case |
|---|---|---|---|
| ubuntu:22.04 | ~77 MB | ~100 | Development only |
| python:3.12 | ~1 GB | ~400 | Development only |
| python:3.12-slim | ~150 MB | ~100 | Better for production |
| python:3.12-alpine | ~50 MB | ~30 | Good for production |
| node:20-alpine | ~130 MB | ~30 | Good for production |
| golang:1.23-alpine | ~250 MB | ~30 | Build stage only |
| gcr.io/distroless/static | ~2 MB | 0 | Best for Go/Rust |
| scratch | 0 MB | 0 | Best for static binaries |
Multi-Stage Builds
Build in a full image, run in a minimal image:
# Stage 1: Build
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server .
# Stage 2: Run (minimal image)
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
USER nonroot
ENTRYPOINT ["/server"]
The final image is ~5 MB instead of ~250 MB.
Rule 3: Scan Images for Vulnerabilities
Every image you use has potential vulnerabilities. Scan before deploying.
Trivy
# Scan a local image
trivy image myapp:latest
# Scan and fail on HIGH or CRITICAL
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest
# Scan a Dockerfile (without building)
trivy config Dockerfile
Docker Scout (Built into Docker)
# Scan an image
docker scout cves myapp:latest
# Quick overview
docker scout quickview myapp:latest
GitHub Actions with Image Scanning
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan image
uses: aquasecurity/trivy-action@v0.20.0
with:
image-ref: myapp:${{ github.sha }}
severity: 'HIGH,CRITICAL'
exit-code: '1'
Rule 4: Manage Secrets Properly
Never put secrets in Dockerfiles, images, or environment variables (visible via docker inspect).
BAD: Secrets in Dockerfile
# NEVER DO THIS — secrets are stored in image layers
ENV DATABASE_URL=postgres://user:password@db:5432/myapp
ENV API_KEY=sk_live_abc123
Even if you delete the ENV in a later layer, the secret is still in the previous layer.
BAD: Secrets in docker-compose.yml
# NEVER DO THIS — secrets in plain text
services:
app:
environment:
- DATABASE_URL=postgres://user:password@db:5432/myapp
GOOD: Docker Secrets (Swarm)
# Create a secret
echo "postgres://user:password@db:5432/myapp" | docker secret create db_url -
# Use in docker-compose (Swarm mode)
services:
app:
image: myapp:latest
secrets:
- db_url
secrets:
db_url:
external: true
In the container, the secret is available as a file at /run/secrets/db_url.
GOOD: Mount secrets as files
# docker-compose.yml
services:
app:
image: myapp:latest
volumes:
- ./secrets/db_url.txt:/run/secrets/db_url:ro
Read the secret in code:
func loadSecret(name string) (string, error) {
data, err := os.ReadFile("/run/secrets/" + name)
if err != nil {
return "", err
}
return strings.TrimSpace(string(data)), nil
}
func main() {
dbURL, err := loadSecret("db_url")
if err != nil {
log.Fatal("Cannot load secret:", err)
}
// Use dbURL
}
def load_secret(name):
with open(f"/run/secrets/{name}") as f:
return f.read().strip()
db_url = load_secret("db_url")
Rule 5: Limit Container Capabilities
By default, Docker containers have many Linux capabilities they do not need.
Drop All Capabilities
# docker-compose.yml
services:
app:
image: myapp:latest
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # Only if binding to port < 1024
Read-Only Filesystem
services:
app:
image: myapp:latest
read_only: true
tmpfs:
- /tmp # Allow writing to /tmp only
Resource Limits
Prevent a compromised container from consuming all host resources:
services:
app:
image: myapp:latest
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.25"
memory: 128M
Rule 6: Minimize Network Exposure
Only expose ports that are needed. Do not expose database ports to the host.
services:
app:
image: myapp:latest
ports:
- "8080:8080" # Only expose the app port
db:
image: postgres:16-alpine
# NO ports section — only accessible within the Docker network
expose:
- "5432" # Only accessible to other containers
Rule 7: Keep Images Updated
Outdated base images contain known vulnerabilities. Update regularly.
# Check for updates
docker pull python:3.12-slim
# Rebuild with latest base
docker build --no-cache -t myapp:latest .
Automate with Dependabot or Renovate:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
Secure Dockerfile Checklist
# 1. Use specific version tags (never :latest in production)
FROM python:3.12.4-slim
# 2. Install only what you need
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# 3. Copy dependency files first (better caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 4. Copy application code
COPY . .
# 5. Create and use non-root user
RUN useradd -r -s /bin/false appuser
USER appuser
# 6. Use HEALTHCHECK
HEALTHCHECK --interval=30s --timeout=3s \
CMD curl -f http://localhost:8000/health || exit 1
# 7. Set read-only where possible
# (use --read-only flag in docker run)
# 8. Document the exposed port
EXPOSE 8000
# 9. Use exec form for CMD (proper signal handling)
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000"]
Prevention Checklist
| Defense | Priority | Notes |
|---|---|---|
| Run containers as non-root (USER directive) | High | Most important Docker security rule |
| Use minimal base images (alpine, distroless, scratch) | High | Fewer packages = fewer vulnerabilities |
| Scan images with Trivy or Docker Scout | High | Automate in CI/CD |
| Never put secrets in Dockerfiles or ENV | High | Use Docker secrets or mounted files |
| Drop all capabilities, add only what is needed | High | cap_drop: ALL in compose |
| Use multi-stage builds | High | Smaller final image |
| Pin base image versions (never use :latest) | Medium | Reproducible builds |
| Enable read-only filesystem | Medium | Prevents file modification |
| Set resource limits (CPU, memory) | Medium | Prevent resource exhaustion |
| Do not expose unnecessary ports | Medium | Minimize attack surface |
| Update base images regularly | Medium | Automate with Dependabot |
Use no-new-privileges security option | Low | Prevents privilege escalation |
What is Next?
In the next tutorial, you will get a Complete Security Checklist — a comprehensive guide covering every topic in this series, organized by priority, that you can use as a reference for every project.