// Harden Docker/container images and runtime deployments with secure base images, non-root users, CVE scanning, SBOM/signing, seccomp/AppArmor, and Kubernetes pod security controls. Use for Dockerfile security reviews, container CVEs, image scanning, distroless images, or production hardening.
Harden Docker/container images and runtime deployments with secure base images, non-root users, CVE scanning, SBOM/signing, seccomp/AppArmor, and Kubernetes pod security controls. Use for Dockerfile security reviews, container CVEs, image scanning, distroless images, or production hardening.
category
security
risk
safe
source
community
date_added
2026-05-30
Container Security Hardening Skill
A production-focused guide for building, scanning, and running containers securely — from Dockerfile authoring through runtime enforcement and supply chain integrity.
When to Use This Skill
User mentions Docker security, container hardening, or Dockerfile security review
User asks about distroless images, non-root containers, or read-only filesystems
User wants to scan images for CVEs with Trivy, Grype, or Snyk
User mentions seccomp, AppArmor, Linux capabilities, or runtime security
User asks "is my Dockerfile secure?" or "how do I reduce my image attack surface?"
User wants to sign/verify images with Cosign or generate SBOMs
User asks about Kubernetes pod security, NetworkPolicy, or RBAC hardening
User says "fix container CVEs" or "harden my container for production"
When NOT to Use This Skill
The user is primarily asking about GitHub Actions CI/CD → recommend github-actions-advanced
The user needs general Docker usage help (not security) → recommend docker-expert
The user is working with Kubernetes orchestration beyond security → recommend kubernetes-architect
The user needs application-level security (SQL injection, XSS) → recommend api-security-best-practices
Step 1: Understand Context Before Responding
When invoked, first detect the current state:
# Find Dockerfiles in the project
find . -name "Dockerfile*" -not -path "*/node_modules/*" | head -10
# Check for existing security toolingls .trivyignore .hadolint.yaml .snyk docker-compose*.yml 2>/dev/null
# Inspect base images currently in use
grep -r "^FROM" $(find . -name "Dockerfile*") 2>/dev/null
# Check if Kubernetes manifests exist
find . -name "*.yaml" -path "*/k8s/*" -o -name "*.yaml" -path "*/manifests/*" | head -10
Then adapt recommendations to:
The tech stack (Node, Python, Go, Java — affects base image choice)
Whether this is Docker-only or Kubernetes-deployed
The CI platform in use (for scanner integration)
The existing base images and how far they are from best practice
Work through layers in order — hardening the image first gives the most leverage.
See references/base-image-comparison.md for a full size/CVE trade-off table.
Layer 1: Dockerfile Hardening
1.1 Use a Minimal Base Image
# ❌ AVOID — massive attack surface (~100–200 CVEs typical)
FROM ubuntu:latest
FROM node:20
# ✅ BETTER — slim variants (glibc, smaller apt footprint)
FROM node:20-slim
FROM python:3.12-slim
# ✅ BEST — distroless (no shell, no package manager, built-in nonroot user)
FROM gcr.io/distroless/nodejs20-debian12
FROM gcr.io/distroless/python3-debian12
FROM gcr.io/distroless/static-debian12 # Go/Rust fully-static binaries
# ✅ ALSO GREAT — Alpine (musl libc; verify app compatibility first)
FROM alpine:3.20
# ✅ ZERO ATTACK SURFACE — for fully static binaries only
FROM scratch
See references/base-image-comparison.md for the full trade-off matrix.
1.2 Multi-Stage Build — Separate Build from Runtime
Never ship build tools, compilers, or dev dependencies in a production image.
# syntax=docker/dockerfile:1
# ── Stage 1: Install & Build ──────────────────────────────
FROM node:20-slim AS builder
WORKDIR /build
COPY package*.json ./
RUN npm ci # Install all deps (including devDeps)
COPY . .
RUN npm run build && npm prune --production
# ── Stage 2: Runtime — minimal, no build tools ────────────
FROM gcr.io/distroless/nodejs20-debian12@sha256:<digest>
LABEL org.opencontainers.image.source="https://github.com/org/repo"
LABEL org.opencontainers.image.revision="${BUILD_SHA}"
LABEL org.opencontainers.image.licenses="MIT"
WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /build/dist ./dist
COPY --from=builder --chown=nonroot:nonroot /build/node_modules ./node_modules
USER nonroot:nonroot # UID 65532 — built into distroless
EXPOSE 3000
CMD ["dist/server.js"]
Go / Rust static binary pattern:
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o app .
FROM scratch # Zero attack surface
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /build/app /app
USER 65532:65532
ENTRYPOINT ["/app"]
1.3 Run as Non-Root User
# For debian/ubuntu-based images — create dedicated user
RUN groupadd -r appgroup --gid 10001 && \
useradd -r -g appgroup --uid 10001 --no-log-init appuser
COPY --chown=appuser:appgroup . /app
USER appuser # Switch before CMD/ENTRYPOINT — never run as root
# ─────────────────────────────────────────────────────────
# For Alpine-based images
RUN addgroup -g 10001 -S appgroup && \
adduser -u 10001 -S appuser -G appgroup
# For distroless — nonroot (UID 65532) is already built in
USER nonroot:nonroot
1.4 Pin Base Images to Digest
# ❌ UNSAFE — tags are mutable; image can be silently overwritten (supply chain attack)
FROM node:20-slim
# ✅ SAFE — SHA256 digest is cryptographically immutable
FROM node:20-slim@sha256:a1b2c3d4e5f6789abcdef0123456789abcdef0123456789abcdef0123456789ab
# ❌ NEVER — secret in ENV or RUN; visible in `docker history` and layer cache
ENV AWS_SECRET_ACCESS_KEY=supersecret
RUN curl -H "Authorization: Bearer $TOKEN" https://api.example.com > config.json
ARG API_KEY # Also unsafe — visible in build args history
# ✅ CORRECT — BuildKit secret mount (never persisted in any layer)
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=api_token \
curl -H "Authorization: Bearer $(cat /run/secrets/api_token)" \
https://api.example.com/config > config.json
# In the Dockerfile — use exec form (no shell interpretation)
ENTRYPOINT ["node", "server.js"] # ✅ exec form
# ENTRYPOINT /bin/sh -c "node..." # ❌ shell form — spawns extra process
# Define a HEALTHCHECK
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"]
# CVE-2023-1234 — only exploitable via X feature, not used in this app
CVE-2023-1234
# CVE-2023-5678 — fix not yet available; tracked in issue #42
CVE-2023-5678
2.2 Grype (Anchore Alternative)
# Install
tmpdir="$(mktemp -d)"trap'rm -rf "$tmpdir"' EXIT
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh \
-o "$tmpdir/grype-install.sh"
sed -n '1,160p'"$tmpdir/grype-install.sh"
sh "$tmpdir/grype-install.sh"# Scan image
grype myapp:latest
# Fail on critical
grype myapp:latest --fail-on critical
# Output SARIF for GitHub Security tab
grype myapp:latest -o sarif > results.sarif
# Pair with Syft for SBOM generation
syft myapp:latest -o cyclonedx-json > sbom.json
grype sbom:sbom.json # Scan the SBOM directly
2.3 Hadolint — Dockerfile Linting
# Run directly
docker run --rm -i hadolint/hadolint < Dockerfile
# With config file
hadolint --config .hadolint.yaml --failure-threshold warning Dockerfile
.hadolint.yaml:
failure-threshold:warningignore:-DL3008# Pin versions in apt-get (allow floating for base layer)trustedRegistries:-gcr.io-ghcr.io-public.ecr.aws
permissions:contents:readsecurity-events:write# Required for uploading SARIFjobs:security-scan:runs-on:ubuntu-24.04timeout-minutes:20steps:-uses:actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683# v4.2.2-name:Buildimagerun:dockerbuild-tmyapp:${{github.sha}}.-name:LintDockerfileuses:hadolint/hadolint-action@54c9adbab1582c2ef04b2016b760714a4bfde3cf# v3.1.0with:dockerfile:Dockerfilefailure-threshold:warning-name:ScanwithTrivyuses:aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8# v0.28.0with:image-ref:myapp:${{github.sha}}format:sarifoutput:trivy-results.sarifseverity:HIGH,CRITICALexit-code:'1'-name:UploadresultstoGitHubSecuritytabuses:github/codeql-action/upload-sarif@4f3212b61783c3c68e8309a0f18a699764811cda# v3.27.1if:always()# Upload even if scan found issueswith:sarif_file:trivy-results.sarif
Layer 3: Runtime Security
3.1 docker run Hardening Flags
docker run \
--read-only \ # Read-only root filesystem
--tmpfs /tmp:noexec,nosuid,size=100m \ # Writable tmpfs for /tmp only
--tmpfs /var/run \ # For PID files if needed
--user 10001:10001 \ # Non-root UID:GID
--cap-drop ALL \ # Drop ALL Linux capabilities
--cap-add NET_BIND_SERVICE \ # Re-add only what's truly needed
--security-opt no-new-privileges:true \ # Prevent privilege escalation via setuid
--security-opt seccomp=seccomp.json \ # Custom seccomp profile
--security-opt apparmor=docker-default \ # AppArmor profile
--pids-limit 100 \ # Prevent fork bombs
--memory 512m \ # OOM protection
--memory-swap 512m \ # Disable swap
--cpus 1.0 \ # CPU limit
--network none \ # No network (if not needed)
--health-cmd "curl -f http://localhost:3000/health || exit 1" \
--health-interval 30s \
myapp:latest
3.2 Linux Capabilities — What to Drop and Keep
Drop ALL, then explicitly add only what your app requires:
Capability
Purpose
Keep?
NET_BIND_SERVICE
Bind ports < 1024
Only if binding a privileged port
CHOWN
Change file ownership
No — set ownership at build time
SETUID / SETGID
Switch user identity
No — drop always
SYS_ADMIN
Broad privileged operations
No — most dangerous capability
NET_ADMIN
Configure network interfaces
No (only network tools)
SYS_PTRACE
Debug/trace processes
No (only debugger containers)
DAC_OVERRIDE
Override file permissions
No — runs as correct user
NET_RAW
Raw sockets (ping)
No (blocked by default seccomp anyway)
Most web apps need zero capabilities.--cap-drop ALL alone is often sufficient.
3.3 Docker Compose Hardening
services:app:image:myapp:latestread_only:trueuser:"10001:10001"tmpfs:-/tmp:noexec,nosuid,size=100m-/var/run:noexec,nosuid,size=10mcap_drop:-ALLcap_add:-NET_BIND_SERVICE# Only if binding port < 1024security_opt:-no-new-privileges:true-seccomp:./references/seccomp-profile-template.jsonpids_limit:100mem_limit:512mmemswap_limit:512mcpus:1.0healthcheck:test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval:30stimeout:5sretries:3start_period:10snetworks:-backend# Only expose externally if truly required# ports: ["8080:8080"]restart:unless-stoppedlogging:driver:json-fileoptions:max-size:"10m"max-file:"3"networks:backend:driver:bridgeinternal:true# No external connectivity unless needed
3.4 Seccomp Profiles
The Docker default seccomp profile blocks ~44 dangerous syscalls. For stricter control:
# Step 1: Audit syscalls your app actually makes
docker run --security-opt seccomp=unconfined \
--name audit-run myapp:latest &
# Capture with strace
strace -c -p $(docker inspect --format '{{.State.Pid}}' audit-run)
# Or with sysdig (more container-friendly)
sysdig -p "%syscall.type" container.name=audit-run | sort -u
# Step 2: Build a custom profile from references/seccomp-profile-template.json# Step 3: Apply it
docker run --security-opt seccomp=references/seccomp-profile-template.json myapp:latest
See references/seccomp-profile-template.json for a minimal starting allowlist for typical web servers.
3.5 AppArmor Profile (Linux hosts)
# Load Docker's default AppArmor profilesudo apparmor_parser -r /etc/apparmor.d/docker-default
# Apply at runtime
docker run --security-opt apparmor=docker-default myapp:latest
# Generate a custom profile
aa-genprof myapp # Interactive — run app under aa-complain mode first
Layer 4: Supply Chain Security
4.1 Sign Images with Cosign (Sigstore — Keyless)
# Install cosign
brew install cosign # macOS# or: https://github.com/sigstore/cosign/releases# Sign after push — keyless via OIDC (no long-lived keys)
cosign sign ghcr.io/org/myapp:latest
# Verify before deploy
cosign verify ghcr.io/org/myapp:latest \
--certificate-identity-regexp="https://github.com/org/repo" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com"
Also requires non-root, read-only FS, drops capabilities, seccomp
5.3 NetworkPolicy — Zero-Trust Networking
# Step 1: Deny all ingress and egress by default in the namespaceapiVersion:networking.k8s.io/v1kind:NetworkPolicymetadata:name:default-deny-allnamespace:productionspec:podSelector: {}
policyTypes: [Ingress, Egress]
---# Step 2: Selectively allow only required trafficapiVersion:networking.k8s.io/v1kind:NetworkPolicymetadata:name:allow-appnamespace:productionspec:podSelector:matchLabels:app:myapppolicyTypes: [Ingress, Egress]
ingress:-from:-namespaceSelector:matchLabels:kubernetes.io/metadata.name:ingress-nginxpodSelector:matchLabels:app.kubernetes.io/name:ingress-nginxports:-port:3000egress:-to:-podSelector:matchLabels:app:postgresports:-port:5432-to:# Allow only cluster DNS-namespaceSelector:matchLabels:kubernetes.io/metadata.name:kube-systempodSelector:matchLabels:k8s-app:kube-dnsports:-port:53protocol:UDP-port:53protocol:TCP
5.4 RBAC — Least Privilege
# Create minimal role — never use wildcardsapiVersion:rbac.authorization.k8s.io/v1kind:Rolemetadata:name:app-readernamespace:productionrules:-apiGroups: [""]
resources: ["configmaps", "secrets"]
resourceNames: ["myapp-config"] # Lock to specific resource namesverbs: ["get"] # Never ["*"]---apiVersion:rbac.authorization.k8s.io/v1kind:RoleBindingmetadata:name:app-reader-bindingnamespace:productionsubjects:-kind:ServiceAccountname:myapp-sanamespace:productionroleRef:kind:Rolename:app-readerapiGroup:rbac.authorization.k8s.io
# Audit what permissions a service account has
kubectl auth can-i --list --as=system:serviceaccount:production:myapp-sa
# Find overly-permissive cluster roles
kubectl get clusterrolebindings -o json | \
jq '.items[] | select(.roleRef.name == "cluster-admin") | .subjects'
5.5 Kyverno Policy Examples
# Require non-root containersapiVersion:kyverno.io/v1kind:ClusterPolicymetadata:name:require-non-rootspec:validationFailureAction:Enforcerules:-name:check-run-as-non-rootmatch:resources:kinds: [Pod]
validate:message:"Containers must not run as root (runAsNonRoot: true required)"pattern:spec:containers:-securityContext:runAsNonRoot:true---# Require image digest pinningapiVersion:kyverno.io/v1kind:ClusterPolicymetadata:name:require-image-digestspec:validationFailureAction:Enforcerules:-name:check-digestmatch:resources:kinds: [Pod]
validate:message:"Images must be pinned to a SHA256 digest, not just a tag"pattern:spec:containers:-image:"*@sha256:*"---# Block privileged containersapiVersion:kyverno.io/v1kind:ClusterPolicymetadata:name:disallow-privilegedspec:validationFailureAction:Enforcerules:-name:check-privilegedmatch:resources:kinds: [Pod]
validate:message:"Privileged containers are not allowed"pattern:spec:containers:-=(securityContext):=(privileged):"false"