Docker in CI/CD
Build, scan, sign, and push from a pipeline.
Your change gets reviewed, merged, and within seconds something automated grabs it. That something is your pipeline, and it is where Docker stops being a laptop tool and becomes infrastructure. CI/CD (Continuous Integration and Continuous Delivery, the automation that fires on every push) builds an image from your Dockerfile, runs the tests inside that exact image, checks it against public lists of known security holes, signs it, and pushes it to a registry. The order of those steps is the whole game. Get it right and a bad image dies on the build machine, days before anyone tries to run it in front of customers.
The whole thing as one workflow
Here is a working GitHub Actions workflow that runs the full chain. It builds the image and loads it into the runner's Docker so the scanner has something local to read, hands it to Trivy, and pushes only if nothing critical turns up. The build step appears twice on purpose. The first pass loads the image locally. The second pass pushes it. Buildx (the modern Docker build engine) keeps every layer it produced in a cache between the two, so the second pass is mostly bookkeeping rather than a rebuild. On a GitHub-hosted runner the Docker daemon (the background service that actually builds and runs containers) is already installed, and the whole virtual machine is thrown away when the job ends, so every build starts on a clean engine. On a runner you host yourself, that isolation is your responsibility, which is exactly where the warning further down bites.
name: build-scan-pushon:push:branches: [main]permissions:contents: readpackages: writeid-token: write # lets Cosign fetch an OIDC token for keyless signingenv:IMAGE: ghcr.io/${{ github.repository }}jobs:release:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v4- uses: docker/setup-buildx-action@v3- uses: sigstore/cosign-installer@v3- name: Log in to the registryuses: docker/login-action@v3with:registry: ghcr.iousername: ${{ github.actor }}password: ${{ secrets.GITHUB_TOKEN }}- name: Build and load into the local daemonuses: docker/build-push-action@v7with:context: .load: truetags: ${{ env.IMAGE }}:${{ github.sha }}- name: Scan with Trivy, fail on CRITICALuses: aquasecurity/[email protected]with:image-ref: ${{ env.IMAGE }}:${{ github.sha }}severity: CRITICALignore-unfixed: trueexit-code: '1'- name: Push and capture the digestid: pushuses: docker/build-push-action@v7with:context: .push: truetags: ${{ env.IMAGE }}:${{ github.sha }}- name: Sign the digest (keyless)run: cosign sign --yes ${{ env.IMAGE }}@${{ steps.push.outputs.digest }}
Push that file to a repository, open the Actions tab, and a healthy run reads top to bottom like this. Layers get exported locally, Trivy comes back with zero criticals, the push prints the manifest digest, and Cosign records a signature against that digest.
Build and load into the local daemon#14 exporting to docker image format#14 exporting layers 2.1s done#14 sending tarball 1.4s done#14 DONE 3.7sScan with Trivy, fail on CRITICAL2026-07-17T09:14:25Z INFO Detected OS family="debian" version="12.5"ghcr.io/acme/api:9f3c1a2 (debian 12.5)Total: 0 (CRITICAL: 0)Node.js (node-pkg)Total: 0 (CRITICAL: 0)Push and capture the digest#18 pushing ghcr.io/acme/api:9f3c1a2 with docker#18 pushed sha256:7d1f9c3b4a8e... 4.2s done#18 DONE 4.4sSign the digest (keyless)Generating ephemeral keys...Retrieving signed certificate from Fulcio...tlog entry created with index: 148203377Pushing signature to: ghcr.io/acme/api
Fail on CRITICAL, and mean it
A supermarket recall list tells staff which batch numbers to pull off the shelf. Trivy holds the same kind of list for software. It reads every operating-system package and language dependency baked into your image, notes the exact version of each one, and looks those versions up in the public CVE feeds (Common Vulnerabilities and Exposures, the catalogues that give every known security hole an identifier). The report is the boring part. The exit code is the gate. --severity CRITICAL decides what counts as a hit, --exit-code 1 turns a hit into a failed job instead of a wall of text nobody reads, and --ignore-unfixed throws away findings with no patch released yet, so a merge is never blocked by a bug you have no way to fix. Run it by hand against a bad build and you can watch the gate close.
$ trivy image --severity CRITICAL --ignore-unfixed --exit-code 1 \ghcr.io/acme/api:a4e77b1
2026-07-17T09:20:11Z INFO [vuln] Vulnerability scanning is enabled2026-07-17T09:20:12Z INFO Detected OS family="debian" version="12.5"ghcr.io/acme/api:a4e77b1 (debian 12.5)======================================Total: 0 (CRITICAL: 0)Node.js (node-pkg)==================Total: 1 (CRITICAL: 1)┌──────────┬────────────────┬──────────┬────────┬───────────────────┬───────────────┐│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │├──────────┼────────────────┼──────────┼────────┼───────────────────┼───────────────┤│ minimist │ CVE-2021-44906 │ CRITICAL │ fixed │ 1.2.5 │ 1.2.6 │└──────────┴────────────────┴──────────┴────────┴───────────────────┴───────────────┘$ echo $?1
Push by digest, not by tag
A tag like :latest or :v2 works the way a sticky note works on a filing cabinet. Anyone with push rights can peel it off tomorrow and stick it on a different image, and now :v2 points somewhere else while the label still reads the same. A digest behaves differently. It is a sha256 fingerprint, a long hash computed from the exact bytes of the image, so it can only ever refer to those bytes. When a Kubernetes cluster checks a signature, or you roll back at 2am to the build you know was good, the fingerprint is what you want. The sticky note can lie. That is why the workflow reads the digest straight out of the push step and signs that, instead of signing a tag.
The signing here is keyless, which sounds like a marketing word until you see what replaces the key. Hotels stopped cutting metal keys and started printing cards that stop working at checkout. Sigstore does that for signatures. GitHub hands Cosign a short-lived OIDC token (OpenID Connect, a standard way for one service to prove to another who is calling) that names the repository and the workflow currently running. Sigstore issues a throwaway certificate against that token, signs with it, and records the identity in a public transparency log anyone can read. Nothing long-lived sits in a secret waiting to leak. Later, anyone holding the digest can check which repository built the image, which workflow file did it, and that nobody has touched the bytes since.
$ cosign verify \--certificate-identity-regexp '^https://github.com/acme/api/' \--certificate-oidc-issuer https://token.actions.githubusercontent.com \ghcr.io/acme/api@sha256:7d1f9c3b4a8e...
Verification for ghcr.io/acme/api@sha256:7d1f9c3b4a8e... --The following checks were performed on each of these signatures:- The cosign claims were validated- Existence of the claims in the transparency log was verified offline- The code-signing certificate was verified using trusted certificate authority certificates[{"critical":{"identity":{"docker-reference":"ghcr.io/acme/api"},"image":{"docker-manifest-digest":"sha256:7d1f9c3b4a8e..."},"type":"cosign container image signature"},"optional":{"Issuer":"https://token.actions.githubusercontent.com","Subject":"https://github.com/acme/api/.github/workflows/build-scan-push.yml@refs/heads/main"}}]
Build, test, scan, sign, push
The pipeline should produce the artifact you actually run, not a close relative of it. Build with BuildKit, run the test suite inside that image or against the container it starts, scan for known vulnerabilities, sign the digest, and push by digest. A latest tag is a convenience for humans reading a dashboard. The digest is the contract. Fail the pipeline on critical findings, and fail it again if anything unsigned tries to get promoted to production.
Cache aggressively and carefully. Registry-backed cache and the GitHub Actions cache both work well, as long as no secret ends up baked into a cached layer. Choosing between a scanner that blocks and a scanner that only warns is a judgement call about how much risk your team can carry. Start by blocking on CRITICAL alone, then tighten once the noise settles down. The signatures you produce here with Cosign or notation pay off later, when a Kubernetes admission policy starts refusing anything unsigned.
Never hand the Docker socket to a build triggered by a pull request from a fork. That is untrusted code running on your hardware with your credentials nearby. Ephemeral builders and rootless engines keep the damage local when it goes wrong. Record the digest in the deployment pull request too, so a rollback cites a hash rather than somebody's memory of which build was fine.
A minimal pipeline shape: checkout → buildx build → test → trivy → cosign sign → push. Each stage gates the next. If your registry cannot enforce immutable tags, enforce it in policy instead, so nobody can retag over the name production is pinned to.
When you wire this into a real repository, keep the receipts. Four facts are enough: the digest the push step printed, the workflow run number, the Trivy version that produced the verdict, and the commit it all came from. Six weeks later, when someone asks how a CVE reached production, those four answer it in a minute. Rolling back is cheap for the same reason, because the previous digest is still sitting in the registry and still signed. Paste the exact flags into the ticket along with the Total: 0 (CRITICAL: 0) line you saw on a healthy run, so the next person can replay it without asking you what normal looks like.
Try this
Run these on a lab engine. Docker 24 or newer is fine. Read the sample output first, so you know what a healthy run looks like before production depends on the command.
$ docker buildx build -t ghcr.io/example/app:git-abc123 --load .$ trivy image --exit-code 1 --severity CRITICAL ghcr.io/example/app:git-abc123# PASS — no CRITICAL$ cosign sign --yes ghcr.io/example/app@sha256:4f8c1a9b2d4e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8$ docker push ghcr.io/example/app:git-abc123# STATUS: SUCCESS — built, scanned, signed, pushed
Takeaway
The pipeline owns the road from Dockerfile to registry. Build the bits you tested, scan them, sign the digest, push by digest, and keep untrusted jobs nowhere near docker.sock. What production pins is a fingerprint. A floating tag is only a promise from whoever last had push access.
--ignore-unfixed. What does that flag actually do?--severity CRITICAL. --ignore-unfixed is a separate filter.minimist CVE-2021-44906 CRITICAL fixed Installed 1.2.5 Fixed 1.2.6 and the job exits 1, even though --ignore-unfixed is set. Why did it still fail, and what turns it green?