Here's a question every DevOps engineer eventually faces: "Our containers are running in production — how do we know they're actually secure?" The uncomfortable truth is that most container security incidents aren't sophisticated zero-day exploits. They're misconfigurations: images running as root, secrets baked into layers, capabilities that should never have been granted, and images with known CVEs shipped straight to production.
This guide walks through the container security measures that matter most, in the order you should implement them. Every section has a concrete, copy-pasteable example you can adopt today.
1. Start with the Image: Scan Everything, Every Time
Your image is the attack surface. If it contains a vulnerable library, every deployment inherits that risk. Scanning should happen in two places: in CI before the image is pushed, and in the registry on a schedule (new CVEs are published daily, long after your image was built).
# Trivy in CI — fail the build on critical/high findings
trivy image --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed \
myapp:${IMAGE_TAG}
# Scan the running image on demand
docker scan myapp:latest
Trivy, Grype, and Snyk are all solid choices. The key is making the scan block the pipeline — a scan that only warns is a scan that gets ignored. Pair it with a base image policy: prefer minimal distroless or Alpine images. Fewer packages means fewer CVEs, period.
An image that took 10 minutes to build should not take 2 minutes to ship without being scanned. Put the scan between the build and the push.
2. Never Run as Root
By default, a container runs as root inside its namespace. If an attacker escapes the container, they inherit root privileges on the host's user namespace — and a single misconfigured mount can turn that into full host compromise. The fix is almost free:
# Dockerfile: create a non-root user
FROM node:20-slim
RUN groupadd -r appuser && useradd -r -g appuser appuser
USER appuser
COPY --chown=appuser:appuser . /app
WORKDIR /app
CMD ["node", "server.js"]
In Kubernetes, enforce it at the platform level so developers can't forget:
securityContext:
runAsNonRoot: true
runAsUser: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
That block is the single highest-value security change you can make in a pod spec. readOnlyRootFilesystem stops a compromised process from writing to its own filesystem (any write attempts fail loudly), and dropping ALL capabilities strips the kernel privileges most apps never need. If your app must write to disk, mount an emptyDir volume at the specific path.
3. Lock Down the Kernel: seccomp and AppArmor
Containers share the host kernel. seccomp filters the syscalls a process can make — if an exploit tries mount() or ptrace() and they're not in the allowlist, the syscall returns EPERM instead of executing. Docker's default profile blocks roughly 44 dangerous syscalls and is a genuine control, but for production you want your own stricter profile.
# docker run with a custom seccomp profile
docker run --security-opt seccomp=./seccomp-profile.json myapp
# Kubernetes: reference a profile from the node's /var/lib/kubelet/seccomp/
securityContext:
seccompProfile:
type: Localhost
localhostProfile: profiles/audit.json
Start with the runtime default profile, run your app under load, then record which syscalls it actually uses (strace -c is your friend) and build a minimal allowlist. AppArmor on Ubuntu hosts and SELinux on RHEL/CentOS provide the same idea at the LSM layer — Kubernetes supports both via appArmorProfile and seLinuxOptions. Defense in depth means layering these, not picking one.
4. Secrets Never Belong in Images
The classic mistake: ENV DB_PASSWORD=supersecret in the Dockerfile. That secret is now in every image layer, in every registry copy, and in docker history forever — even if you change it later. Purging it means rebuilding the entire image chain. Secrets must be injected at runtime:
# Kubernetes: mount secrets as files, not env vars when possible
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
type: Opaque
stringData:
password: "change-me-now"
---
# In your deployment
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: password
For anything beyond pet projects, use a dedicated secrets manager — Vault, AWS Secrets Manager, or GCP Secret Manager — and let your app fetch credentials at startup instead of storing them anywhere in the cluster. And if you ever accidentally commit a secret, rotate it immediately and treat it as compromised. There's no such thing as "minor exposure."
5. Watch What Happens at Runtime
Image scanning finds known vulnerabilities. It cannot catch novel behavior — a reverse shell, an unexpected curl to an external IP, a process spawning from /tmp. That's runtime detection's job, and Falco is the standard open-source choice. Falco hooks into the kernel (eBPF) and alerts on suspicious syscall patterns:
# Example Falco rule — shell inside a container is a red flag
- rule: Terminal shell in container
desc: A shell was spawned in a container (potential container escape)
condition: >
spawned_process and container and shell_procs and
not proc.name in (docker_execve) and not user_expected_terminal
output: "Shell spawned in container (user=%user.name container=%container.id shell=%proc.name)"
priority: WARNING
Falco's default ruleset already covers the most common attack patterns — spawning shells, reading sensitive files, unexpected outbound connections. Wire its alerts into your alerting stack (the same Prometheus/Alertmanager or Grafana pipeline you already run) so a suspicious event pages someone, not just a log line.
6. Sign Your Images and Pin Your Dependencies
Supply chain attacks are the fastest-growing container threat. The workflow: someone compromises a popular open-source package, you pull an image that includes it, and your "trusted" build now contains malware. Two countermeasures matter most:
- Pin everything. Use exact base image digests (
node:20-slim@sha256:...) and lockfiles for your dependencies.latesttags are nondeterministic by design — the image you tested is not the image you deployed. - Sign your images.
cosignfrom the Sigstore project signs images with keyless signatures tied to your OIDC identity, and policy engines (Kyverno, OPA/Gatekeeper) can require a valid signature before admitting a pod:
# Sign on push
cosign sign ghcr.io/myorg/myapp@sha256:abcdef...
# Verify before deploy
cosign verify ghcr.io/myorg/myapp@sha256:abcdef... \
--certificate-identity-regexp ".*@myorg\.com" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
With Kyverno's verifyImages rule, unsigned images are rejected at admission time — the cluster refuses to run anything you didn't sign, no matter who tries to deploy it.
7. The 10-Minute Security Baseline
Don't try to implement everything at once. Here's the order that gives you the most risk reduction per hour of effort:
- Add image scanning to CI and make critical findings block the pipeline. (30 minutes)
- Apply the hardened
securityContext— non-root, read-only rootfs, drop ALL capabilities — to every workload. (1 hour) - Move secrets out of images into Kubernetes Secrets or a secrets manager. (2 hours)
- Deploy Falco with default rules and hook alerts into your existing monitoring. (2 hours)
- Add image signing with cosign and an admission policy once the rest is stable. (half a day)
Most teams stop after step 2 and have already eliminated the majority of realistic attack paths. Container security isn't about buying an expensive platform — it's about the boring, consistent application of these fundamentals. Do them all, and your containers stop being the weakest link in your infrastructure.