The image is what you actually ship
Your source code is not what runs in production. A container image is: your code plus an operating system layer, a runtime, dozens of libraries and whatever else ended up in the build. Most of those components are not written by you, and vulnerabilities in them are discovered constantly. If the pipeline simply builds and pushes, you inherit those risks silently.
An image stage that builds, scans and publishes under clear rules is one of the most valuable parts of a CI/CD pipeline. It catches known problems before deployment, produces a trail for audits, and keeps the registry tidy. Here is how we build it.
What good looks like
- Build once. The image tested is the image deployed, referenced by digest.
- Small and pinned. Multi-stage builds and slim, version-pinned base images keep both size and findings low.
- Scanned before publishing. A vulnerable image does not reach the registry’s deployable tags.
- Policy with exceptions. Thresholds are explicit, and accepted risks are recorded with an owner and expiry.
- Continuously rechecked. Images are rescanned as new vulnerabilities are published.
- Traceable. Every image links back to a commit, a pipeline run and an SBOM.
Building a clean image
Scanning cannot rescue a poor image. Findings are mostly a function of what is inside, so we start with the Dockerfile:
- A multi-stage build keeps compilers and build tools out of the runtime image.
- A minimal base such as a slim or distroless image removes packages you never use.
- A non-root
USERlimits what a compromised process can do. - Pinned base image versions (and ideally digests) make builds reproducible. Floating tags mean the same Dockerfile produces different images on different days.
Caching these builds properly is covered in our build caching strategy.
The scan stage
Trivy is our usual scanner: open source, fast, and covering OS packages, language dependencies, misconfiguration and secrets in a single tool. Other scanners exist, and the shape of the pipeline stays the same if you use another.
name: image
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
build-scan-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build (load locally, do not push yet)
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan with Trivy
uses: aquasecurity/[email protected]
with:
image-ref: app:${{ github.sha }}
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: "1"
format: table
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/ci-ecr-push
aws-region: eu-west-1
- uses: aws-actions/amazon-ecr-login@v2
id: ecr
- name: Tag and push
run: |
docker tag app:${{ github.sha }} ${{ steps.ecr.outputs.registry }}/app:${{ github.sha }}
docker push ${{ steps.ecr.outputs.registry }}/app:${{ github.sha }}
A few choices are worth explaining. The image is built and loaded locally first, so nothing is pushed until the scan passes. ignore-unfixed: true ignores vulnerabilities that have no available fix, since failing the build on something nobody can fix is pointless, though we still report them. severity: CRITICAL,HIGH sets the blocking threshold. The version of the scan action is pinned, and we recommend pinning to a commit SHA for third-party actions in sensitive pipelines. Check the action’s current release when adopting this.
Authentication uses OIDC rather than stored keys, as described in CI/CD secrets management. The same flow works for ACR (through a federated service principal) and Google’s registry (through workload identity federation).
Handling findings sensibly
A scanner that blocks every build over a medium finding in a library you cannot upgrade will be switched off within a month. We agree a policy up front:
- Block: critical and high, fixable.
- Report: everything else, as a build summary or ticket.
- Exceptions: recorded in a
.trivyignorefile or a VEX statement with a reason, owner and review date, reviewed in pull requests like code.
Also scan the repository and infrastructure files (trivy fs, trivy config) to catch Dockerfile and Kubernetes misconfiguration, such as running as root or missing resource limits.
Registry hygiene
The registry is part of the system. We configure:
- Immutable tags, so a tag cannot be overwritten with different content.
- Lifecycle policies to expire untagged and old images, which controls storage cost and clutter.
- Least-privilege push and pull roles: CI can push, nodes can pull, humans rarely do either.
- Registry-side scanning where available, such as ECR scanning, as a second signal after the image ships.
- Cross-region or cross-account replication when your deployment topology needs it.
For clusters, pulling by digest matters. A tag points at whatever was last pushed, whereas a digest never changes. Our Kubernetes deployment work deploys by digest, and if you run on AWS, our Amazon EKS guide covers the registry and node side.
SBOMs and signing
A software bill of materials lists what is inside an image. Trivy or Syft can generate one in CycloneDX or SPDX format, and storing it alongside the image helps when the next widely publicised vulnerability appears and you need to know within minutes which services are affected. Signing images with Cosign and enforcing signatures at admission goes one step further, proving provenance. We recommend adding both after scanning is stable, rather than trying to adopt everything at once.
Decisions and trade-offs
Scan time versus coverage. Scanning adds a minute or so. Caching the vulnerability database and scanning only on merge to main (with a lighter check on pull requests) keeps feedback fast.
CI scan versus registry scan. CI scanning stops bad images at the gate. Registry or scheduled scanning catches those that aged badly. You want both.
Hardened images. Distroless or minimal images reduce findings and attack surface but are harder to debug, since there is no shell. Ephemeral debug containers in Kubernetes mitigate this.
False positives. Scanners report vulnerabilities in code paths you do not use. Reachability analysis is imperfect, so the policy must allow justified exceptions rather than pretending the scanner is always right.
When not to bother yet. A prototype with no customers does not need SBOMs and signing. A basic scan on the main branch is still cheap insurance.
Common failure modes
- Pushing before scanning, which makes the scan advisory.
latesttags in deployments.- Secrets baked into layers, retrievable by anyone with pull access.
- Alert fatigue from unfiltered findings.
- One scan at build time, never again, so old images quietly decay.
- Unbounded registry growth with no lifecycle rules.
Delivery and handover
We standardise the Dockerfile pattern, build the scan and push stage as a shared template, configure registry settings in code (usually Terraform), and agree the severity policy with your security contact. You receive documentation for adding exceptions and a scheduled job that reports on images already in use. If you want to see how this fits with the rest of your delivery, look at the parent service overview or get in touch.
How to tell it is working
Measure the number of critical and high findings in deployed images, median age of base images, the share of deployments referencing a digest, time to patch after a new critical disclosure, and the volume of active exceptions. Healthy signs are shrinking image sizes, a short exception list and fast turnaround on base image updates.