Docker & ContainersXXXVI Β· AutomationCI/CD
CI/CD for Docker images β build, scan, sign, push
What you'll learn
- Order pipeline stages so a failing gate prevents publication rather than reporting it
- Resolve the ordering conflict between scanning, signing and pushing
- Promote images by digest, and explain the attack that tag promotion allows
- State what a passing scan does and does not prove
- Verify signature and provenance at deploy time, not only at build time
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12
A pipeline that builds, scans, signs and pushes is not a security control by virtue of containing those words. It is a control only if a failing stage can prevent the outcome that stage exists to prevent.
That reduces almost entirely to ordering, and ordering is where most real pipelines are wrong.
The ordering problem
flowchart LR
subgraph Wrong[Common and wrong]
W1[Build] --> W2[Push] --> W3[Scan] --> W4[Report]
end
subgraph Right[What a gate looks like]
R1[Build] --> R2[Test] --> R3[Scan] --> R4[Publish]
R3 -. fail .-> R5[Nothing published]
end
The top pipeline is extremely common, usually because pushing first is the easiest way to give the scanner something to scan. It produces a red build, a Slack message, and a vulnerable image sitting in your registry with a production tag on it.
From that moment:
- Anything with a
latestor environment tag can pull it. A node that restarts a container, an autoscaler, adocker compose pullβ none of them consult your CI status. - Deleting it does not undo it. Registries retain manifests by digest, and anything that already resolved the tag holds the digest.
- The pipeline reported a failure and the artefact shipped anyway. That is worse than no scan, because the dashboard now says the control exists.
The rule is a single sentence: a gate must run before the irreversible step, and publication is the irreversible step.
The conflict that makes this awkward
Here is the part the five-stage diagram hides, and it is a genuine engineering conflict rather than a matter of taste.
Cosign signs images that live in a registry. A signature is stored as an OCI artefact alongside the image, addressed by the imageβs digest, in the same repository. There is no local image to sign β the thing being signed is a manifest at a registry address.
So βsign before pushβ is not achievable in the literal sense. The stage order everyone writes down is not implementable as written.
A pipeline that is actually a gate
The workflow below uses option 3 for clarity, with the build-once property made explicit, and notes where option 2 slots in.
# .github/workflows/docker.yml
name: Docker CI
on:
push:
branches: [main]
permissions:
contents: read
id-token: write # required for cosign keyless signing
packages: write
env:
IMAGE: registry.example.com/myorg/myapp
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
# Build ONCE. --load puts the result in the local image store so the
# test and scan stages operate on the exact bytes that will be pushed.
- name: Build
run: |
docker buildx build \
--load \
--provenance=mode=max \
--sbom=true \
--tag "$IMAGE:ci-${{ github.sha }}" \
.
- name: Test inside the image
run: |
docker run --rm --network none \
"$IMAGE:ci-${{ github.sha }}" \
python -m pytest tests/
# No docker socket mount. Trivy reads the local image store directly,
# and mounting the socket into a scanner would give it host root.
- name: Scan β this is the gate
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.IMAGE }}:ci-${{ github.sha }}
severity: HIGH,CRITICAL
ignore-unfixed: true
exit-code: '1'
# Nothing below this line runs if the scan failed.
- name: Log in to the registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" \
| docker login -u "${{ secrets.REGISTRY_USER }}" \
--password-stdin registry.example.com
- name: Push and capture the digest
id: push
run: |
docker push "$IMAGE:ci-${{ github.sha }}"
DIGEST=$(docker buildx imagetools inspect \
"$IMAGE:ci-${{ github.sha }}" --format '{{.Manifest.Digest}}')
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
- uses: sigstore/cosign-installer@v3
# Sign the DIGEST, never the tag. See the section below.
- name: Sign
run: |
cosign sign --yes "$IMAGE@${{ steps.push.outputs.digest }}"
# Only now does a tag anything deploys from start pointing at it.
- name: Promote
run: |
docker buildx imagetools create \
--tag "$IMAGE:1.0.0" \
"$IMAGE@${{ steps.push.outputs.digest }}"
This is what the gate looks like when it does its job. The non-zero exit is
the entire mechanism β without --exit-code 1, Trivy prints the same table
and returns success, and every step below it runs anyway:
$ trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 \
registry.example.com/myorg/myapp:ci-4f2a91c
echo "exit=$?"myapp (debian 12.7)
Total: 2 (HIGH: 1, CRITICAL: 1)
ββββββββββββββββ¬βββββββββββββββββ¬βββββββββββ¬βββββββββ¬ββββββββββββββββ¬ββββββββββββββββ
β Library β Vulnerability β Severity β Status β Installed Ver β Fixed Ver β
ββββββββββββββββΌβββββββββββββββββΌβββββββββββΌβββββββββΌββββββββββββββββΌββββββββββββββββ€
β libexpat1 β CVE-0000-00000 β CRITICAL β fixed β 2.5.0-1 β 2.5.0-1+deb12 β
β libssl3 β CVE-0000-11111 β HIGH β fixed β 3.0.11-1 β 3.0.15-1 β
ββββββββββββββββ΄βββββββββββββββββ΄βββββββββββ΄βββββββββ΄ββββββββββββββββ΄ββββββββββββββββ
exit=1Illustrative output
Four things in the workflow differ from the usual example and each is deliberate:
--loadthen scan then push, so one build artefact goes through every stage. Twobuildx buildinvocations produce two images and break the chain of custody.- No
-v /var/run/docker.sock:/var/run/docker.sockon the scanner. A container with the Docker socket has root on the host running it, which on a self-hosted runner is your build infrastructure. Trivy does not need it β it reads the local image store, or the registry directly over the distribution API withtrivy registry login. - The digest is captured and used from then on.
- The deployable tag is created last, from the digest, after the
signature exists. Until that step, nothing that deploys
myapp:1.0.0can find anything.
Digests, and the tag you must not trust
What a passing scan proves
Verify at deploy, not only at build
A signature nobody checks is a checksum on a box nobody opens.
#!/bin/bash
set -euo pipefail
IMAGE=registry.example.com/myorg/myapp
TAG=1.0.0
IDENTITY='https://github.com/myorg/myapp/.*'
ISSUER='https://token.actions.githubusercontent.com'
# 1. Resolve the mutable tag exactly once.
DIGEST=$(docker buildx imagetools inspect "$IMAGE:$TAG" \
--format '{{.Manifest.Digest}}')
REF="$IMAGE@$DIGEST"
echo "deploying $REF"
# 2. Verify the signature against the digest, with an identity constraint.
# Without --certificate-identity-regexp, ANY valid Sigstore signature
# passes, including one made by an attacker with their own account.
cosign verify "$REF" \
--certificate-identity-regexp "$IDENTITY" \
--certificate-oidc-issuer "$ISSUER" >/dev/null
# 3. Verify the build provenance, not just that someone signed it.
cosign verify-attestation "$REF" --type slsaprovenance \
--certificate-identity-regexp "$IDENTITY" \
--certificate-oidc-issuer "$ISSUER" >/dev/null
# 4. Only now pull, and pull the digest so the tag cannot change under us.
docker pull "$REF"
echo "verified and pulled $REF"Failure modes worth rehearsing
| Symptom | Usual first diagnosis | What it usually is |
|---|---|---|
| Scan passes in CI, fails in the nightly job | Scanner version drift | --ignore-unfixed in one and not the other |
cosign verify fails only for some images | Registry replication lag | The tag was moved; the signature is against the old digest |
| SBOM attestation missing from a pushed image | Buildx version | Built with --load, which cannot carry attestations |
| Pipeline suddenly fails on an unchanged Dockerfile | Bad commit | A new CVE was published against the base image |
| Deploy pulls an old image | Registry cache | A tag resolved at a different time than expected |
The middle row is the one to internalise: a red pipeline on a commit that changed a README is usually correct and usually urgent, and the reflex to add an ignore rule so the build goes green is how a vulnerability reaches production through a control that was working.
- Build once. Every later stage operates on that artefact, never on a rebuild.
- Test and scan before anything is publishable, with the scan set to a non-zero exit code so the pipeline actually stops.
- Push to a location nothing deploys from, or push last β but never leave an unscanned image under a deployable tag.
- Capture the digest at push time and use it for every subsequent step.
- Sign the digest, with keyless OIDC if your CI supports it.
- Create the deployable tag last, from the digest, once signing has succeeded.
- Verify at deploy with an identity constraint, then pull by digest.
- Re-scan what is deployed on a schedule. The build-time scan expires the moment it finishes.
Knowledge check
Knowledge check Β· 6 questions
Q1. A pipeline runs build, push, scan, report. The scan finds a CRITICAL vulnerability and the build goes red. What is the state of the system?
Q2. Why can the stage order not literally be build, scan, sign, push?
Q3. Which statements about image tags and digests are true? Select all that apply.
Q4. Your pipeline fails a scan on a commit that only edited a README. What is the most likely cause?
Q5. `cosign verify myapp@sha256:...` with no certificate identity or issuer flags is an effective deploy-time control.
Q6. Trivy can scan an image straight from a registry over the distribution API, with no access to the Docker socket at all.
Passing score: 75%. Answers are checked in this browser.