Containers feel “clean” because they’re packaged, repeatable, and disposable. That’s exactly why attackers love them too: a single weak image, a permissive runtime, or an over-privileged service account can turn one container into a cluster-wide incident.
This guide gives you a practical, step-by-step container security system that you can actually implement—whether you’re using Docker locally or Kubernetes in production.
We’ll cover the three pillars that stop most real-world container incidents:
- Image scanning (stop vulnerable/malicious stuff before it ships)
- Runtime policies (stop bad behavior while it runs)
- Least privilege (limit blast radius when something goes wrong)
And we’ll do it with real examples, checklists, and copy/paste configs.

The container security “movie plot” (how incidents usually happen)
Most container incidents follow the same story:
- A container image contains a vulnerable package (or a leaked secret).
- The container runs with extra privileges (root, broad capabilities, writable filesystem).
- The container has access to too much (K8s API permissions, cloud metadata, open network).
- Attacker gets inside → escalates → moves laterally → steals data.
If you fix those three pillars, the story ends early.
Pillar 1: Image Scanning (shift security left)
Image scanning answers: “What’s inside this image, and is it safe enough to deploy?”
What you’re scanning for (the big 5)
- Known vulnerabilities in OS packages and libraries
- Secrets accidentally baked into images (tokens, keys, passwords)
- Malware or suspicious binaries
- Risky configurations (running as root, SSH server inside, etc.)
- Supply chain integrity (is this image what you think it is?)
The beginner mistake
Scanning only at deploy time or only in prod.
You want scanning in two places:
- CI build pipeline (fast feedback to devs)
- Registry / continuous scanning (catches new CVEs in old images)
Step-by-step: Build a “secure image pipeline” (simple version)
Step 1 — Use a minimal base image (reduce attack surface)
A smaller base image = fewer packages = fewer vulnerabilities.
Bad pattern: “One big image with everything”
Better pattern: Minimal runtime image + build tools kept in a builder stage (multi-stage build)
Example Dockerfile (multi-stage, smaller runtime)
# Build stage (has compilers, build tools)
FROM node:20 as builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Runtime stage (smaller, fewer packages)
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
# Create non-root user
RUN useradd -m appuser
USER appuser
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
What improved?
- Build tools never ship into prod image
- Runs as non-root
- Smaller image = fewer packages to patch
Step 2 — Generate an SBOM (so you can see what you ship)
SBOM (Software Bill of Materials) is basically a manifest of what libraries/packages are in the image.
Why it matters:
- When a new CVE hits, you can instantly answer: “Are we affected?”
- It improves audit readiness and incident response
In practice: generate SBOM during CI and store it with the image metadata.
Step 3 — Set policy gates (so scanning becomes actionable)
Scanning is useless if everything still gets deployed.
A practical starting policy:
✅ Block builds if:
- critical vulnerabilities exist and a fix is available
- secrets are detected
- image runs as root (for production workloads)
⚠️ Warn only if:
- vulnerabilities exist but no fix yet (track and patch later)
- medium/low issues (use SLOs)
Real example: “CVE gate” rule you can explain to anyone
- Critical + fix available → block
- Critical + no fix → allow temporarily, but require exception + ticket
- Secrets found → block always
Step 4 — Scan for secrets (because this one mistake is catastrophic)
Common secret leaks:
.envfiles copied into image- cloud credentials in build logs
- private keys committed by accident
Rule: If secret scanning finds something, treat it like it already leaked.
Rotate it. Don’t just “remove it.”
Step 5 — Sign images (so you can trust what you deploy)
This is supply chain security in one line:
Only deploy images that are signed by your CI.
It prevents:
- deploying random images
- “someone pushed a new :latest” surprises
- registry compromise turning into prod compromise
Image scanning quick checklist (copy/paste into your SOP)
- Minimal base image / multi-stage builds
- No secrets in images (scan + rotation process)
- SBOM generated and stored per build
- Vulnerability gate rules (block/warn/exception)
- Continuous registry scanning (new CVEs on old images)
- Images signed; only signed images can deploy
Pillar 2: Runtime Policies (stop bad behavior while running)
Even a “clean” image can be abused at runtime:
- Remote code execution in your app
- Misconfig → shell access
- Dependency compromised after build
- An attacker uses your container as a launching pad
Runtime security is about: “Even if compromised, what is the container allowed to do?”
There are two layers:
- Preventive controls (don’t allow dangerous configs to start)
- Detective controls (alert/block suspicious actions while running)
Layer A: Preventive runtime policies (Kubernetes examples)
1) Enforce “non-root containers only”
A surprising number of container breakouts begin with “runs as root.”
Kubernetes SecurityContext example:
securityContext:
runAsNonRoot: true
runAsUser: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
Why this matters:
runAsNonRootprevents root user inside the containerallowPrivilegeEscalation: falsestops “sudo-like” escalationreadOnlyRootFilesystem: truestops many persistence tricks
2) Drop Linux capabilities (reduce kernel-level power)
Linux capabilities are like “mini-root permissions.” Most apps don’t need them.
Best practice: drop all, add back only what you truly need.
securityContext:
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"] # only if you need ports <1024
3) Disable privileged containers (unless you really need them)
Privileged containers are basically “host-level access.”
Treat privileged as an exception requiring approval and isolation.
securityContext:
privileged: false
4) Apply Pod Security Standards (baseline/restricted)
If you do nothing else, aim for “restricted” for most namespaces.
Your goal: make “unsafe pods” fail at admission time.
5) Control what images can run (allowlists)
Allow images only from:
- your organization registry
- trusted vendor registries you approve
This prevents “random public image” surprises.
Layer B: Detective runtime security (behavior monitoring)
Runtime behavior to watch:
- unexpected shell execution (
/bin/sh,bash) - spawning package managers (
apt,yum,apk) in production - writing to sensitive paths
- network calls to unknown destinations
- access to Kubernetes API from pods that shouldn’t
- container trying to mount host paths / access socket
/var/run/docker.sock
A simple “production rule” that catches many attacks
In production containers, block or alert on interactive shells.
If you need debugging, use a separate debug workflow—not shelling into app containers.
Runtime policy checklist (practical)
- Admission control blocks privileged pods and root users
- Capabilities dropped by default
- Read-only filesystem for stateless apps
- Image allowlist (trusted registries only)
- Alerting for shells, package installs, suspicious syscalls
- Namespace isolation for high-risk workloads
Pillar 3: Least Privilege (limit blast radius)
Least privilege means: every container gets only the access it needs—nothing more.
This includes:
- OS-level permissions (user, filesystem, capabilities)
- Kubernetes permissions (RBAC, service accounts)
- Network permissions (who can talk to whom)
- Cloud permissions (IAM roles for pods/workloads)
- Secrets permissions (read only what you need)
Step-by-step least privilege (Kubernetes-first approach)
Step 1 — Use dedicated ServiceAccounts per app
Bad: everything uses the default service account
Better: one service account per app/namespace, minimal RBAC
Example:
apiVersion: v1
kind: ServiceAccount
metadata:
name: payments-sa
namespace: payments
automountServiceAccountToken: false
That last line matters a lot:
- If your app doesn’t need Kubernetes API, don’t mount the token.
Step 2 — Tighten RBAC (no wildcards)
Bad RBAC:
verbs: ["*"]resources: ["*"]
Better RBAC:
- allow only specific verbs and resources
- scope to a namespace
- avoid secrets access unless required
Example (read configmaps only):
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: payments-read-config
namespace: payments
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: payments-read-config-binding
namespace: payments
subjects:
- kind: ServiceAccount
name: payments-sa
roleRef:
kind: Role
name: payments-read-config
apiGroup: rbac.authorization.k8s.io
Step 3 — Use NetworkPolicies (stop lateral movement)
Without NetworkPolicies, many clusters are “flat networks” where pods can talk freely.
Start with this safe model:
- default deny ingress/egress for each namespace
- explicitly allow only needed traffic
Example: default deny ingress:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: payments
spec:
podSelector: {}
policyTypes: ["Ingress"]
Then add allow rules for:
- ingress from your gateway/ingress controller
- egress to databases or dependencies
- DNS
This alone reduces breach spread dramatically.
Step 4 — Restrict secrets exposure
Rule: A pod should only receive secrets it needs.
Practical tips:
- use separate secrets per app (not one giant secret blob)
- mount secrets as files (read-only) instead of env vars when possible
- keep secret rotation process documented
- don’t let apps list secrets via RBAC
Step 5 — Cloud least privilege (if using AWS/Azure/GCP)
This is huge: if a pod gets a broad cloud role, compromise becomes a cloud account incident.
Best practice:
- one role per workload
- minimal permissions
- short sessions
- no wildcard resources if possible
Least privilege checklist (fast)
- Non-root user, no privilege escalation
- Drop all capabilities, add only required
- Dedicated service account per workload
automountServiceAccountToken: falseunless needed- Minimal RBAC (no
*) - NetworkPolicies (default deny + explicit allow)
- Minimal cloud IAM permissions per workload
- Secrets scoped, rotated, never baked into images
Put it together: The “Golden Path” for secure containers
If you want a workflow that scales to hundreds of services, adopt this “golden path”:
1) Build
- multi-stage Dockerfile
- minimal base image
- no secrets in build context
2) Scan
- vulnerability scan + secrets scan
- generate SBOM
- block based on policy
3) Sign + Push
- sign image
- push to registry
- enable continuous scanning
4) Deploy with guardrails
- admission policies enforce baseline security
- namespace defaults are “restricted”
- only trusted registries allowed
5) Runtime detection
- alert on shells/package installs/unexpected network calls
- monitor sensitive syscalls
- incident playbook ready
This is how security becomes systematic, not “best effort.”
Real-world examples (the kind you actually see)
Example 1: “Why is my node scaling so high?”
Cause: pods request huge CPU/memory “just in case”
Security tie-in: oversized resources lead to waste and noisy clusters where abnormal behavior hides easier.
Fix:
- rightsize requests
- enforce resource requests/limits policy
- use separate namespaces for prod vs non-prod
Example 2: “Attacker got shell inside pod, then what?”
If you did least privilege well:
- they can’t become root (
runAsNonRoot) - they can’t install tools (
readOnlyRootFilesystem) - they can’t reach everything (
NetworkPolicy) - they can’t query K8s API (
automountServiceAccountToken: false) - they can’t steal cloud creds (minimal IAM)
The attack becomes a contained incident, not a disaster.
Example 3: “We used a public image, and later it changed”
Fix:
- pin images by digest (immutable)
- allow only signed images
- only allow trusted registries
- avoid
:latestin production
A 30/60/90-day container security rollout plan
Days 1–30 (foundation)
- image scanning in CI (vulns + secrets)
- minimal base images + multi-stage builds
- block critical + fix available
- ban
:latestin prod
Days 31–60 (guardrails)
- enforce non-root, no privilege escalation
- drop capabilities by default
- Pod Security “restricted” for most namespaces
- service accounts per app + minimal RBAC
Days 61–90 (maturity)
- sign images + deploy-only-signed
- NetworkPolicies default deny
- runtime detection for shells/suspicious actions
- documented exception process (time-limited)
Common mistakes (so you don’t waste months)
- “We scan, but we don’t block.”
That’s reporting, not security. - “We enforce policies, but exceptions last forever.”
Exceptions must expire. - “Everything uses default service account.”
That’s an invitation. - “We allow privileged for convenience.”
Privileged should be rare, isolated, and reviewed. - “We focus on CVEs only.”
Secrets + permissions + network are often the real breach path.
The one-page “Container Security Scorecard” (use this in reviews)
If your platform team needs a fast review tool, rate each workload:
Image
- Minimal base? SBOM? secrets scan? signed? pinned digest?
Runtime
- Non-root? no privilege escalation? read-only FS? capabilities dropped?
Access
- Dedicated SA? token off by default? minimal RBAC? network policy?
If any category is weak, you know exactly where to fix.
Final takeaway
Container security becomes easy when you stop treating it as one big thing and start treating it as three controllable layers:
- Image scanning stops known bad inputs
- Runtime policies stop dangerous behavior
- Least privilege limits damage when something slips through