Containers aren’t inherently secure. A default Docker image runs as root, includes unnecessary packages, and exposes more attack surface than needed. Let’s fix that.

Secure Dockerfile Practices

Use Minimal Base Images

1
2
3
4
5
6
7
8
# BAD: Full OS with unnecessary packages
FROM ubuntu:latest

# BETTER: Slim variant
FROM python:3.11-slim

# BEST: Distroless (no shell, no package manager)
FROM gcr.io/distroless/python3-debian11

Size comparison:

  • ubuntu:latest: ~77MB
  • python:3.11-slim: ~45MB
  • distroless/python3: ~16MB

Smaller image = smaller attack surface.

Multi-Stage Builds

Keep build tools out of 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
27
28
# Build stage
FROM python:3.11 AS builder

WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

COPY . .
RUN python -m compileall .

# Production stage
FROM python:3.11-slim

# Create non-root user
RUN useradd --create-home --shell /bin/bash appuser

WORKDIR /app

# Copy only what's needed
COPY --from=builder /root/.local /home/appuser/.local
COPY --from=builder /app/*.pyc ./

# Switch to non-root user
USER appuser

ENV PATH=/home/appuser/.local/bin:$PATH

CMD ["python", "app.pyc"]

Never Run as Root

1
2
3
4
5
6
7
8
9
# Create user with specific UID/GID
RUN groupadd -g 1001 appgroup && \
    useradd -u 1001 -g appgroup -s /sbin/nologin appuser

# Set ownership
COPY --chown=appuser:appgroup . /app

# Switch to non-root
USER appuser

Pin Versions

1
2
3
4
5
6
7
# BAD: Mutable tags
FROM python:latest
RUN pip install flask

# GOOD: Pinned versions
FROM python:3.11.7-slim-bookworm@sha256:abc123...
RUN pip install flask==3.0.0 --no-cache-dir

Scan for Vulnerabilities

1
2
3
4
5
6
7
8
# Trivy - fast and comprehensive
trivy image myapp:latest

# Docker Scout (built-in)
docker scout cves myapp:latest

# Grype
grype myapp:latest

Integrate into CI:

1
2
3
4
5
6
7
# .github/workflows/security.yml
- name: Scan image
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: myapp:${{ github.sha }}
    exit-code: '1'
    severity: 'CRITICAL,HIGH'

Kubernetes Security

Security Context

 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
36
37
apiVersion: apps/v1
kind: Deployment
metadata:
  name: secure-app
spec:
  template:
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 1001
        runAsGroup: 1001
        fsGroup: 1001
        seccompProfile:
          type: RuntimeDefault
      
      containers:
      - name: app
        image: myapp:v1.0.0
        securityContext:
          allowPrivilegeEscalation: false
          readOnlyRootFilesystem: true
          capabilities:
            drop:
              - ALL
        
        # Writable directories for app needs
        volumeMounts:
        - name: tmp
          mountPath: /tmp
        - name: cache
          mountPath: /app/.cache
      
      volumes:
      - name: tmp
        emptyDir: {}
      - name: cache
        emptyDir: {}

Pod Security Standards

1
2
3
4
5
6
7
8
9
# Namespace-level enforcement
apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted

Network Policies

Default deny, explicit allow:

 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
# Deny all ingress by default
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
  namespace: production
spec:
  podSelector: {}
  policyTypes:
  - Ingress

---
# Allow specific traffic
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-api-ingress
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: api
  policyTypes:
  - Ingress
  ingress:
  - from:
    - namespaceSelector:
        matchLabels:
          name: ingress-nginx
    - podSelector:
        matchLabels:
          app: frontend
    ports:
    - protocol: TCP
      port: 8080

Resource Limits

Prevent resource exhaustion attacks:

1
2
3
4
5
6
7
8
9
containers:
- name: app
  resources:
    requests:
      memory: "128Mi"
      cpu: "100m"
    limits:
      memory: "256Mi"
      cpu: "200m"

Secrets Management

Never in Images

1
2
3
4
5
6
# NEVER do this
ENV API_KEY=secret123
COPY .env /app/

# Instead, mount at runtime
# Or use Kubernetes secrets

Kubernetes Secrets

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
apiVersion: v1
kind: Secret
metadata:
  name: api-credentials
type: Opaque
stringData:
  api-key: "your-secret-key"

---
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
      - name: app
        env:
        - name: API_KEY
          valueFrom:
            secretKeyRef:
              name: api-credentials
              key: api-key

External Secrets

For production, use external secret stores:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# External Secrets Operator
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: api-credentials
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: ClusterSecretStore
  target:
    name: api-credentials
  data:
  - secretKey: api-key
    remoteRef:
      key: production/api/credentials
      property: api_key

Runtime Security

Read-Only Filesystem

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Application code must handle read-only root
import tempfile
import os

# Use temp directory for runtime files
CACHE_DIR = os.environ.get('CACHE_DIR', tempfile.gettempdir())

# Write to mounted volume, not root filesystem
with open(f'{CACHE_DIR}/data.json', 'w') as f:
    json.dump(data, f)

Liveness Without Shell

With distroless images, no shell is available for exec probes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Use HTTP probes instead
livenessProbe:
  httpGet:
    path: /health
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 15

# Or gRPC
livenessProbe:
  grpc:
    port: 50051

Admission Controllers

Enforce policies at deploy time:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Kyverno policy: require non-root
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-non-root
spec:
  validationFailureAction: Enforce
  rules:
  - name: check-non-root
    match:
      resources:
        kinds:
        - Pod
    validate:
      message: "Containers must run as non-root"
      pattern:
        spec:
          containers:
          - securityContext:
              runAsNonRoot: true

Security Checklist

Image Build

  • Minimal base image (distroless/alpine/slim)
  • Multi-stage build
  • Non-root user
  • Pinned versions (including digest)
  • No secrets in image
  • Vulnerability scan in CI

Kubernetes Deployment

  • runAsNonRoot: true
  • readOnlyRootFilesystem: true
  • allowPrivilegeEscalation: false
  • capabilities.drop: ALL
  • Resource limits set
  • Network policies applied
  • Pod Security Standards enforced

Runtime

  • Secrets from external store
  • Read-only filesystem
  • No privileged containers
  • Admission controllers active
  • Runtime scanning enabled

Quick Wins

If you do nothing else, do these:

1
2
3
4
5
6
securityContext:
  runAsNonRoot: true
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false
  capabilities:
    drop: ["ALL"]

These four settings prevent the majority of container escape vulnerabilities. Start here, then layer on additional controls.

Container security isn’t a single tool or setting—it’s layers of defense. Each layer you add makes exploitation harder. Start with the basics, scan continuously, and treat security as an ongoing practice, not a checkbox.