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:
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.