The IaC workflow split infrastructure security into its own reusable file. This is its companion for the other artefact every service produces: the container image.
The case for splitting it out is the same, plus one thing unique to containers. Only repos that ship an image need it. It triggers on Dockerfile and build-context changes, not every push. And it is the natural — really, the only sensible — home for the SBOM, the software bill of materials that your supply-chain and audit obligations increasingly require. That last point makes this workflow do something the others do not: it produces evidence, not just a pass or fail.
Verified. A real GitHub Actions workflow on the public Range repo; the run below is real.
Step 1 — Lint, scan, and an SBOM
.github/workflows/container-security.yml:
name: container-security
on:
push:
paths: ['docker/**', '.github/workflows/container-security.yml']
pull_request:
paths: ['docker/**']
workflow_call: # reusable across every service repo
workflow_dispatch:
permissions:
contents: read
jobs:
dockerfile-lint: # hadolint — reads the Dockerfile, no build
image-scan: # build → Dockle (CIS) + Trivy (CVEs)
sbom: # build → Syft SBOM → retained artifactThe paths: ['docker/**'] filter and the workflow_call trigger do the same job they did in the IaC workflow — run only when the container changes, and let any repo reuse the whole thing in one line. If you have not read that lesson's Steps 2 and 3, they explain both; the mechanics are identical here.
Step 2 — The three jobs, fastest first
dockerfile-lint:
steps:
- uses: actions/checkout@v4
- uses: hadolint/hadolint-action@v3.1.0
with: { dockerfile: docker/Dockerfile }
image-scan:
steps:
- uses: actions/checkout@v4
- run: docker build -f docker/Dockerfile -t range-app:ci .
- id: dockle
continue-on-error: true
run: docker run --rm -v /var/run/docker.sock:/var/run/docker.sock goodwithtech/dockle:v0.4.15 --exit-code 1 --exit-level fatal range-app:ci
- id: trivy
continue-on-error: true
uses: aquasecurity/trivy-action@0.28.0
with: { image-ref: range-app:ci, severity: HIGH,CRITICAL, ignore-unfixed: true, exit-code: 1 }
- run: |
[ "${{ steps.dockle.outcome }}" = failure ] || [ "${{ steps.trivy.outcome }}" = failure ] && exit 1 || trueThe ordering is deliberate and it is the container chapter's structure in miniature:
- hadolint reads the Dockerfile — no build required, so it is the fast pre-check that fails in seconds if the recipe is wrong.
- Dockle reads the built image — it sees what actually shipped: the credentials baked into a layer, the root user, the setuid binaries the base dragged in.
- Trivy scans the image's packages for known CVEs,
--ignore-unfixedso the gate stays passable.
Dockle and Trivy share the continue-on-error + final-gate pattern from the capstone, so both report before the job blocks.
Step 3 — The SBOM job is the point of difference
sbom:
steps:
- uses: actions/checkout@v4
- run: docker build -f docker/Dockerfile -t range-app:ci .
- uses: anchore/sbom-action@v0
with:
image: range-app:ci
format: spdx-json
output-file: sbom.spdx.json
- uses: actions/upload-artifact@v4
with:
name: sbom
path: sbom.spdx.json
retention-days: 90This job does not gate on anything. It generates the SBOM with Syft and retains it — and that is exactly the point.
The SBOM lesson made the case: the image is 1.85 GB and its SBOM is 16 MB, so keeping the SBOM per build is cheap where keeping images is not; new CVEs land against old builds and a stored SBOM lets you answer "which releases shipped log4j 2.14?" months later without the image. A scan tells you what is wrong today. The SBOM lets you answer questions you have not been asked yet.
retention-days: 90 is a starting point — for a regulated service it is longer, and the SBOM belongs in artifact storage or attached to the image with --provenance. Either way, this job is the difference between a workflow that checks and a workflow that also remembers.
Step 4 — The run
OVERALL: Failure (54s) Artifacts: 2
❌ hadolint (Dockerfile) 10s
❌ Dockle + Trivy (built image) 3s
✅ SBOM (Syft) 50s
Two gates red, one green — and the green is not a security pass. hadolint flagged the Dockerfile (the run shows 5 errors), and Dockle + Trivy flagged the built image: credentials in a layer, and HIGH/CRITICAL CVEs. Both are the container gates doing their job.
The SBOM job is green because it does not gate — it produces. Look at the top of the run: Artifacts: 2. The SBOM was generated and retained, downloadable from the run for ninety days. That green check does not mean "the image is safe"; it means "the record exists". On this deliberately vulnerable image, the gates are red and the evidence was captured — which is precisely the pairing you want. The bad image did not ship, and you still have its bill of materials for the audit.
This is the same lesson as the capstone and the IaC workflow, a third time: a green job is only as meaningful as what it checks. DAST-baseline green meant "passive".
terraform validategreen meant "syntactically valid". SBOM green means "evidence retained". None of the three means "secure". Read the job, not the colour.
Step 5 — Two workflows, or one?
You now have three shapes for the same set of tools:
| Shape | Best for |
|---|---|
| Monolith (every gate, one file) | One application repo, one team, one cadence |
| Reusable IaC workflow | Terraform repos, platform-team ownership |
| Reusable container workflow (this one) | Any repo that ships an image |
Most organisations run a combination: the monolith on the flagship app, and the two reusable workflows called from every other repo — the Terraform modules, the microservices, the base images. The tools are the same throughout this track; how you package them into workflows is the decision that scales — or doesn't — across a hundred repos.
Add these two workflows to any new repo and you have covered its infrastructure and its image in the time it takes to paste two uses: lines. That is the payoff of splitting by concern: not fewer checks, but checks you can move.
What you learned
- A container-security workflow belongs to every repo that ships an image, and — via path filters and
workflow_call— is reused exactly like the IaC one. - Fastest first: hadolint (Dockerfile, no build), then Dockle + Trivy on the built image, with
continue-on-errorso both report before blocking. - The SBOM job is the difference: it generates the bill of materials with Syft and retains it as an artifact — evidence, not a gate. The run shows 2 retained artifacts.
- The run was Failure with the two scan gates red and the SBOM job green — and green here means "the record exists", not "the image is safe".
- Green is only as meaningful as what a job checks — passive DAST,
terraform validate, and an SBOM job are all green for reasons that are not "secure". - Monolith for one app; reusable IaC and container workflows for everything else. The tools are constant; the packaging is what scales across many repos.