Journal / Technical notes

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

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.

Software Supply ChainCatch the CVE before production does
⚙
Build
🛡
Trivy scan
✍
Cosign sign
⛔
Kyverno admit
🚀
Production
⛔Build fails on CRITICAL/HIGH CVEs. Unsigned or unscanned images are rejected at the cluster door.
0
unscanned images admitted
100%
of admitted images signed
3
open-source tools, no license fees

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:18

That 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: true

With 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.dev

Now 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 .trivyignore files 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.

Frequently Asked Questions

Let's make your
infrastructure work better.

Lower cloud costs, more reliable deployments,
and systems your team can operate with confidence.