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: ~77MBpython:3.11-slim: ~45MBdistroless/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#
Kubernetes Deployment#
Runtime#
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.