DevSecOps for Docker: Automate Container Image Security
Stop deploying vulnerable Docker base images. Learn how to build an automated DevSecOps pipeline to scan and catch CVEs before they reach production.
On this page
- TL;DR
- The Log4Shell Wake-Up Call
- Why Docker Hub is Dangerous
- Step 0: Shrink the Attack Surface Before You Scan
- Deep Dive 1: Scanning Every Build with Trivy
- Deep Dive 2: Signing Images with Cosign
- Deep Dive 3: Admission Control (The Last Line of Defense)
- The Operational Reality (What Breaks)
- The Payoff
- Frequently Asked Questions
TL;DR
Every unscanned Docker image in your production cluster is a ticking time bomb.
- The Stack: Trivy (Container Scanning), Cosign (Image Signing), OPA/Kyverno (Admission Policies), and GitHub Actions (Automated Enforcement).
- The Verdict: The cost of a security breach is catastrophic. The cost of automating container security into your CI/CD pipeline is a few days of engineering work.
Preparing for SOC 2 or ISO 27001? Enterprise customers increasingly ask for proof that your container images are scanned, signed, and governed by admission policies. The pipeline in this article produces that proof: failed builds for known CVEs, signatures tied to your CI, and a cluster that rejects everything else. It maps directly onto the vulnerability-management and change-management controls auditors ask about.
The Log4Shell Wake-Up Call
In December 2021, Log4Shell (CVE-2021-44228) exposed a brutal reality: a single vulnerable dependency buried deep inside a Docker image can compromise your entire infrastructure.
Teams with automated container scanning could list every affected image within hours. Teams without it spent days or weeks checking containers by hand.
If your security strategy is "we'll scan it when we have time," you don't have a security strategy. You have a liability.
moarkid.com — DevSecOps
The question isn't if your Docker images have vulnerabilities. They do. The question is whether you catch them before or after an attacker does.
Why Docker Hub is Dangerous
Most engineering teams start their Dockerfiles with something like this:
FROM node:18That single line pulls an image from Docker Hub that contains an entire operating system, hundreds of packages, and potentially dozens of known CVEs. You are trusting that every maintainer of every package in that image has perfect security hygiene.
Spoiler: they don't.
Full Debian-based images like node:18 ship hundreds of OS packages your app never uses, and node:18 itself is past end of life, so its vulnerabilities no longer get fixed. Run trivy image node:18 and look at the count yourself.
Step 0: Shrink the Attack Surface Before You Scan
The cheapest vulnerability to fix is the one that isn't in the image. A multi-stage build keeps compilers and dev dependencies out of production, and a distroless runtime drops the shell and package manager entirely:
Dockerfile
# Build stage: full toolchain, never shipped
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
# Runtime stage: no shell, no package manager, runs as non-root
FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["dist/server.js"]Fewer packages means fewer CVEs for Trivy to report, fewer false positives to triage, and a smaller, faster image to pull (which also means less NAT Gateway data processing if your nodes pull through one; see the NAT Gateway guide).
Deep Dive 1: Scanning Every Build with Trivy
Trivy (by Aqua Security) is an open-source vulnerability scanner that integrates directly into your CI/CD pipeline. It scans your Docker images, Terraform files, and even your package-lock.json for known CVEs.
Here is how to block vulnerable images from ever reaching production:
.github/workflows/security-scan.yml
name: Container Security Scan
on:
push:
branches: [main]
pull_request:
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker Image
run: docker build -t app:${{ github.sha }} .
- name: Run Trivy Scan
# Pin third-party actions to a release (ideally its commit SHA), never
# a moving branch like @master: a compromised branch runs in your CI.
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: app:${{ github.sha }}
format: table
exit-code: 1 # FAIL the build on critical CVEs
severity: CRITICAL,HIGH
ignore-unfixed: trueWith exit-code: 1, the CI pipeline fails if Trivy finds any CRITICAL or HIGH severity vulnerability. The developer cannot merge the PR until the vulnerability is fixed. No exceptions. No "we'll fix it later."
Deep Dive 2: Signing Images with Cosign
Scanning catches known vulnerabilities. But how do you prevent someone from pushing a tampered or unauthorized image directly to your container registry?
Cosign (part of the Sigstore project) lets you cryptographically sign your Docker images during CI/CD. Then, your Kubernetes cluster can be configured to only accept images that carry a valid signature.
The simplest setup today is keyless signing: GitHub Actions proves its identity to Sigstore with an OIDC token, so there is no private key to store or rotate. Here is the full pipeline, build, scan, push, then sign the exact digest that was scanned:
.github/workflows/build-scan-sign.yml
name: Build, Scan, Sign
on:
push:
branches: [main]
permissions:
contents: read
packages: write # push to GHCR
id-token: write # keyless signing with Sigstore
jobs:
release:
runs-on: ubuntu-latest
env:
IMAGE: ghcr.io/${{ github.repository }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build
run: docker build -t "$IMAGE:${{ github.sha }}" .
- name: Scan (fail on fixable HIGH/CRITICAL)
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ env.IMAGE }}:${{ github.sha }}
exit-code: 1
severity: CRITICAL,HIGH
ignore-unfixed: true
- name: Push and capture digest
id: push
run: |
docker push "$IMAGE:${{ github.sha }}"
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE:${{ github.sha }}")
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
- uses: sigstore/cosign-installer@v3
- name: Sign by digest (keyless)
run: cosign sign --yes "${{ steps.push.outputs.digest }}"Sign the digest, not the tag. Tags can be moved to point at a different image; a digest cannot.
Once the cluster verifies signatures (the verifyImages rule in the next section), an attacker who pushes a malicious image to your registry still can't run it: it lacks a valid signature. This is the container equivalent of HTTPS for your supply chain.
Deep Dive 3: Admission Control (The Last Line of Defense)
Even with scanning and signing in CI/CD, a rogue engineer could kubectl apply a pod with a random, unscanned image from Docker Hub. You need a policy engine inside the cluster itself.
Kyverno is a Kubernetes-native policy engine that enforces rules at admission time:
block-unscanned-images.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-image-scan
spec:
validationFailureAction: Enforce
rules:
- name: check-image-registry
match:
any:
- resources:
kinds:
- Pod
validate:
message: >-
Images must come from the approved registry
(ghcr.io/your-org). Public Docker Hub images
are blocked in production.
pattern:
spec:
containers:
- image: "ghcr.io/your-org/*"With this policy, any attempt to deploy an image from Docker Hub or any other registry is automatically rejected by the Kubernetes API server.
The registry rule alone doesn't check signatures. Add a verifyImages rule so the cluster also refuses images that were not signed by your CI workflow:
verify-signatures.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-signed-images
spec:
validationFailureAction: Enforce
rules:
- name: verify-cosign-keyless
match:
any:
- resources:
kinds:
- Pod
verifyImages:
- imageReferences:
- "ghcr.io/your-org/*"
attestors:
- entries:
- keyless:
subject: "https://github.com/your-org/*"
issuer: "https://token.actions.githubusercontent.com"
rekor:
url: https://rekor.sigstore.devNow an image has to come from your registry and carry a signature from your GitHub Actions workflows. The cluster protects itself.
The Operational Reality (What Breaks)
Implementing DevSecOps is not painless. Here is what to expect:
- False Positives: Trivy will flag vulnerabilities in OS packages your application doesn't even use. Use
.trivyignorefiles wisely, but document every exception with a justification. - Developer Friction: When a CI pipeline starts blocking PRs because of a CVE in a transitive dependency, developers will push back. You need buy-in from engineering leadership that security gates are non-negotiable.
- Base Image Maintenance: If you pin base images to an exact version or digest for reproducibility, you also need a process to rebuild when patches are released (Dependabot or Renovate can open those PRs for you). Stale pinned images become vulnerable too.
The Payoff
IBM's Cost of a Data Breach report put the global average breach at $4.45 million in 2023 and $4.88 million in 2024. The entire pipeline described in this article fits in a single sprint.
By shifting security scanning into CI/CD, signing images cryptographically, and enforcing admission policies at the cluster level, you create a defense-in-depth architecture that catches threats at every layer.
Your Docker images will stop being a liability and start being a hardened, auditable, production-grade artifact.
The same pipeline is where cost checks belong too: see how to add Infracost to pull requests in my AWS cost optimization playbook, and how to roll out safely in the zero-downtime CI/CD blueprint.
Not running Kubernetes? Most of the risk on a single Docker server is closer to home: containers running as root, secrets in a .env committed to Git, and no alert when something restarts at 3 AM. That's the cheaper place to start.