Secure containerized applications from image building to runtime. Covers image scanning, minimal base images, rootless containers, runtime security, Kubernetes security contexts, network policies, and the patterns that protect containerized workloads.
Container Security Hardening is the process of locking down containerized workloads from creation to runtime, ensuring that images, configurations, and execution environments are resilient against known and emerging threats. It matters when containers are the primary deployment unit across development, staging, and production environments—especially in microservices architectures, CI/CD pipelines, and Kubernetes clusters.
Ensure every container image is minimal, verifiable, and tamper-resistant from the moment it is built.
Use multi-stage builds to reduce image size and surface area.
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o main main.go
FROM alpine:3.20 AS runner
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /app/main .
COPY --from=builder /app/config.yaml ./config.yaml
EXPOSE 8080
USER 1001:1001
ENTRYPOINT ["/app/main"]
The USER 1001:1001 directive ensures the application runs as a non-root user, reducing privilege escalation risk. The image size drops from ~200 MB (golang base) to ~25 MB (alpine + runtime-only binaries).
Validate image integrity using SBOMs and cryptographic signatures.
syft . -o spdx-xml -o json -o cyclonedx-json -o table > image.sbom.json
# Sign image with cosign
cosign sign --key cosign.key ghcr.io/myorg/myapp:v1.2.3
Verify the signature:
cosign verify --key cosign.pub ghcr.io/myorg/myapp:v1.2.3
Expected output:
Verified OK
If verification fails, cosign verify returns:
{
"error": "signature verification failed",
"details": [
"signature not signed by key",
"timestamp mismatch",
"image digest mismatch"
]
}
Use --certificate-identity to enforce image ownership policies.
Scan images for vulnerabilities and enforce policies during CI.
# .github/workflows/build.yml
- name: Scan Image
run: |
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:v1.2.3
trivy image --exit-code 1 --severity CRITICAL myapp:latest
# Fail if any critical or high severity vulnerability
# Exit code 1: failure; 0: success
Trivy outputs detailed results:
{
"Results": [
{
"Target": "myapp:v1.2.3",
"Class": "os",
"Type": "alpine",
"Vulnerabilities": [
{
"VulnerabilityID": "CVE-2023-1234",
"PkgName": "openssl",
"InstalledVersion": "3.1.0-r0",
"FixedVersion": "3.1.0-r1",
"Severity": "HIGH",
"Title": "OpenSSL: Buffer overflow in SSL/TLS handshake"
}
]
}
]
}
Use --exit-code 1 to fail the build on any high or critical vulnerability. Add --ignore-unfixed to suppress noise from unfixed packages.
Sign and distribute images using a single, shared key.
# Generate key pair
cosign generate-key-pair --key-passphrase-stdin
# Sign and push image
cosign sign --key cosign.key --message "production-ready" ghcr.io/myorg/myapp:v1.2.3
# Push signed image with OCI artifact
cosign attach artifact --artifact-type "application/vnd.myorg.config.v1+json" \
--file config.json \
ghcr.io/myorg/myapp:v1.2.3
Verify the attached artifact:
cosign verify-attestation \
--type "application/vnd.myorg.config.v1+json" \
ghcr.io/myorg/myapp:v1.2.3
Ensure containers run with minimal privileges and enforce security policies at runtime.
Define resource limits and security context in Kubernetes manifests.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-server
spec:
replicas: 4
selector:
matchLabels:
app: api-server
template:
metadata:
labels:
app: api-server
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
supplementalGroups: [1002]
seccompProfile:
type: LocalPath
localPath: runtime/default.json
containers:
- name: main
image: ghcr.io/myorg/api-server:v1.2.3
ports:
- containerPort: 8080
resources:
limits:
memory: "512Mi"
cpu: "500m"
requests:
memory: "256Mi"
cpu: "200m"
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
seccompProfile:
type: LocalPath
localPath: runtime/app.json
env:
- name: ENV
value: "production"
The readOnlyRootFilesystem: true prevents any changes to the root filesystem during execution. capabilities.drop: ["ALL"] disables all Linux capabilities, leaving only CAP_NET_BIND_SERVICE and CAP_SYSLOG.
Define system call filtering using seccomp profiles.
// runtime/app.json
{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{
"names": ["openat", "read", "write", "exit_group", "getpid"],
"action": "SCMP_ACT_ALLOW"
},
{
"names": ["mmap", "mmap2"],
"action": "SCMP_ACT_ERRNO",
"errno": 38
},
{
"names": ["socket", "connect"],
"action": "SCMP_ACT_ERRNO",
"errno": 32
}
]
}
When a container makes a system call not in the profile, it fails with errno 38 (EPERM). The mmap call returns -1 with errno 38, and the process logs:
2024-05-12T10:30:00Z app[main]: mmap failed: Operation not permitted
Enable AppArmor profiles as a layer of defense.
# Install AppArmor profile for the container
cat <<EOF | sudo tee /etc/apparmor.d/container.api-server
#include <tunables/global>
profile container.api-server {
#include <abstractions/base>
#include <abstractions/network>
#include <abstractions/filesystem>
# Allow read-only access to configuration
/etc/api-server/** r,
/etc/api-server/*.yaml r,
# Allow writing to logs
/var/log/api-server/ rw,
/var/log/api-server/*.log rw,
# Restrict file access
/usr/bin/api-server ix,
/app/ r,
/app/** r,
/app/bin/ r,
# Allow network operations
network tcp,
network udp,
# Deny all other system calls
deny capability all,
}
EOF
# Load profile
sudo apparmor_parser -r /etc/apparmor.d/container.api-server
Attach profile to container:
apiVersion: v1
kind: Pod
metadata:
name: api-server-pod
spec:
securityContext:
apparmor:
profile: container.api-server
containers:
- name: main
image: ghcr.io/myorg/api-server:v1.2.3
If a container tries to execve a binary not in /app/bin, AppArmor logs:
audit: type=1400 audit(1715523400.123456): apparmor="DENIED" operation="execve" profile="container.api-server" name="/app/bin/migration" pid=1234
Enforce policies at runtime using Open Policy Agent (OPA) and Kyverno.
# kyverno/policies/container-hardening.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: enforce-container-hardening
spec:
background: true
rules:
- name: require-read-only-root
match:
resources:
kinds:
- Pod
validate:
message: "Root filesystem must be read-only."
pattern:
spec:
containers:
- (name):
readOnlyRootFilesystem: true
failureMessage: "Root filesystem is not read-only."
- name: require-seccomp
match:
resources:
kinds:
- Pod
validate:
message: "Seccomp profile must be defined."
pattern:
spec:
securityContext:
seccompProfile:
type: "LocalPath"
localPath: "runtime/*"
- name: require-apparmor
match:
resources:
kinds:
- Pod
validate:
message: "AppArmor profile must be set."
pattern:
spec:
securityContext:
apparmor:
profile: "container.*"
Kyverno evaluates policies on every Pod creation and updates. If readOnlyRootFilesystem is missing, the Pod fails to be created with:
{
"apiVersion": "kyverno.io/v1",
"kind": "PolicyReport",
"metadata": {
"name": "container-hardening-report"
},
"results": [
{
"policy": "enforce-container-hardening",
"rule": "require-read-only-root",
"resource": {
"kind": "Pod",
"name": "api-server-7d8b9c",
"namespace": "prod"
},
"message": "Root filesystem must be read-only.",
"status": "failure",
"details": [
{
"field": "spec.containers[0].readOnlyRootFilesystem",
"expected": true,
"actual": false
}
]
}
]
}
Protect data at rest and in transit.
Mount secrets as files with strict permissions.
apiVersion: v1
kind: Pod
metadata:
name: web-app
spec:
containers:
- name: web
image: nginx:alpine
volumeMounts:
- name: app-config
mountPath: /etc/app
readOnly: true
- name: secrets
mountPath: /run/secrets
readOnly: true
volumes:
- name: app-config
configMap:
name: app-config
- name: secrets
secret:
secretName: db-credentials
items:
- key: username
path: db-username
mode: 0400
- key: password
path: db-password
mode: 0400
The mode: 0400 ensures only the root user can read the secrets. The readOnly: true setting ensures no container can write to /run/secrets.
Ensure all volumes are mounted with xattr and noexec flags.
# Mount a volume with extended attributes and no execution
mount -o xattr,noexec,mode=0755 /dev/sdb1 /mnt/data
This prevents arbitrary code execution from the volume and enables metadata tracking.
Use bind mounts with nodev, nosuid, and noexec for runtime data.
apiVersion: v1
kind: Pod
metadata:
name: worker
spec:
containers:
- name: processor
image: worker:v1
volumeMounts:
- name: data
mountPath: /data
subPath: processed
readOnly: true
volumes:
- name: data
hostPath:
path: /var/data/processed
type: Directory
The hostPath volume inherits the filesystem’s xattr and noexec settings. If the host uses xfs with extents, files are efficiently stored, and noexec prevents unintended script execution.
Use shared mount propagation to isolate mount points.
# Mount a volume in a container with shared propagation
mount --bind /host/data /container/data
mount --make-shared /container/data
This ensures any new mount in the container propagates to the host, and vice versa. Use --propagation=slave to make the container’s view a slave of the host.
Automate validation of container configurations and runtime behavior.
Use docker inspect to verify image and runtime state.
docker inspect myapp:v1.2.3 | jq '.[0].Config | {User, WorkingDir, Entrypoint, Cmd, ExposedPorts}'
Output:
{
"User": "1001:1001",
"WorkingDir": "/app",
"Entrypoint": ["/app/main"],
"Cmd": [],
"ExposedPorts": {
"8080/tcp": {}
}
}
Validate runtime behavior using crictl and podman.
# List all containers with their security context
crictl ps --output json | jq '.[].status | {podId, sandboxId, createdAt, startedAt, state, reason}'
# Check seccomp profile application
podman inspect --format '{{.GraphDriver.Data}}' myapp:v1.2.3
Failure: if seccompProfile.localPath is misspelled as seccompProfile.localPath, the runtime fails to load the profile and logs:
error: failed to create container: seccomp profile not found: runtime/app.json
runAsUser vs runAsNonRoot: Setting runAsUser: 1001 without runAsNonRoot: true allows root access even if the user is non-root.readOnlyRootFilesystem without fsGroup: The fsGroup setting is ignored if readOnlyRootFilesystem is enabled.apparmor_parser.ClusterPolicy and the controller must be running.Hardening is not a one-time task. It requires continuous integration of image scanning, signature verification, and runtime policy enforcement. The most common failure is assuming that readOnlyRootFilesystem: true is sufficient—when in fact, it must be paired with allowPrivilegeEscalation: false, capabilities.drop: ["ALL"], and a seccomp profile.
This page was rewritten on 16 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy