A container is only as secure as its weakest layer. Most security breaches don’t exploit exotic vulnerabilities — they walk through doors left open by default configurations, bloated images, and running as root.

Here’s how to actually secure your containers.

Start with Minimal Base Images

Every package in your image is attack surface. Alpine Linux images are ~5MB compared to Ubuntu’s ~70MB. Fewer packages means fewer CVEs to patch.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# ❌ Don't do this
FROM ubuntu:latest
RUN apt-get update && apt-get install -y python3 python3-pip
COPY . /app
CMD ["python3", "/app/main.py"]

# ✅ Do this
FROM python:3.12-alpine
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app
CMD ["python3", "/app/main.py"]

For compiled languages, use multi-stage builds to ship only the binary:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o app .

# Runtime stage - distroless has no shell, no package manager
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /build/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

The final image contains only your binary. No shell for attackers to spawn, no package manager to install tools.

Never Run as Root

By default, containers run as root. If an attacker escapes the container, they’re root on the host.

1
2
3
4
5
6
7
8
9
# Create non-root user
RUN addgroup -g 1001 appgroup && \
    adduser -u 1001 -G appgroup -D appuser

# Switch to non-root user
USER appuser:appgroup

# Ensure app files are owned correctly
COPY --chown=appuser:appgroup . /app

Kubernetes can enforce this at the pod level:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
apiVersion: v1
kind: Pod
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 1001
    runAsGroup: 1001
    fsGroup: 1001
  containers:
    - name: app
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop:
            - ALL

Pin Your Versions

FROM python:latest is a security incident waiting to happen. Pin everything:

1
2
3
4
5
6
7
# ❌ Mutable tags
FROM python:latest
FROM node:18

# ✅ Immutable digests
FROM python:3.12.1-alpine3.19@sha256:abc123...
FROM node:18.19.0-alpine3.19@sha256:def456...

Get the digest with docker pull <image> && docker inspect --format='{{index .RepoDigests 0}}' <image>.

Scan Images in CI

Catch vulnerabilities before they reach production:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# .github/workflows/security.yaml
name: Container Security
on: [push, pull_request]

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Build image
        run: docker build -t app:${{ github.sha }} .
      
      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: app:${{ github.sha }}
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'  # Fail build on findings
      
      - name: Upload results to GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: 'trivy-results.sarif'

Trivy, Grype, and Snyk all work. Pick one and run it on every build.

Use Read-Only Filesystems

If your app doesn’t need to write to disk, don’t let it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# docker-compose.yml
services:
  api:
    image: myapp:latest
    read_only: true
    tmpfs:
      - /tmp
      - /var/run
    volumes:
      - type: volume
        source: app-data
        target: /data
        read_only: false  # Only this path is writable

This stops attackers from dropping malware or modifying binaries.

Manage Secrets Properly

Never bake secrets into images:

1
2
3
4
5
6
7
8
9
# ❌ Secret in image layer (recoverable with docker history)
ENV DATABASE_PASSWORD=supersecret

# ❌ Secret in build arg (also recoverable)
ARG API_KEY
RUN echo $API_KEY > /app/config

# ✅ Mount secrets at runtime
# docker run -v /secrets/db-password:/run/secrets/db-password app

In Kubernetes, use external secrets operators:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: db-credentials
spec:
  refreshInterval: 1h
  secretStoreRef:
    kind: ClusterSecretStore
    name: vault
  target:
    name: db-credentials
  data:
    - secretKey: password
      remoteRef:
        key: prod/database
        property: password

Limit Resources

Prevent DoS and noisy neighbors:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Kubernetes
resources:
  requests:
    memory: "128Mi"
    cpu: "100m"
  limits:
    memory: "256Mi"
    cpu: "500m"

# docker-compose
deploy:
  resources:
    limits:
      cpus: '0.5'
      memory: 256M
    reservations:
      cpus: '0.1'
      memory: 128M

Network Policies

Default Kubernetes networking allows all pod-to-pod traffic. Lock it down:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-network-policy
spec:
  podSelector:
    matchLabels:
      app: api
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: frontend
      ports:
        - protocol: TCP
          port: 8080
  egress:
    - to:
        - podSelector:
            matchLabels:
              app: database
      ports:
        - protocol: TCP
          port: 5432
    - to:  # Allow DNS
        - namespaceSelector: {}
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - protocol: UDP
          port: 53

Now api can only receive traffic from frontend and only connect to database and DNS.

Runtime Protection

Use security profiles to restrict what containers can do:

1
2
3
4
5
6
7
8
# Seccomp - restrict system calls
securityContext:
  seccompProfile:
    type: RuntimeDefault  # Or use custom profile

# AppArmor - restrict file/network access  
annotations:
  container.apparmor.security.beta.kubernetes.io/app: runtime/default

For high-security environments, consider Falco for runtime threat detection:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Falco rule - alert on shell spawned in container
- rule: Shell Spawned in Container
  desc: Detect shell execution in container
  condition: >
    spawned_process and container and
    proc.name in (bash, sh, zsh, ash)
  output: >
    Shell spawned in container 
    (user=%user.name container=%container.name shell=%proc.name)
  priority: WARNING

The Checklist

Before shipping any container:

  • Minimal base image (Alpine/distroless)
  • Non-root user
  • Pinned versions with digests
  • No secrets in image
  • Vulnerability scan passing
  • Read-only filesystem where possible
  • Resource limits set
  • Network policies applied
  • Capabilities dropped

Security isn’t a feature you add at the end. Bake it into your Dockerfiles from day one, enforce it in CI, and verify it in production. The best container security is the kind attackers never get to test.