CoursesDocker in depthProduction & CI

Docker in CI/CD

Build, scan, sign, and push from a pipeline.

Advanced12 min · lesson 30 of 30

Docker’s real home is the CI/CD pipeline: every push builds an image, tests run inside a known-good container, the image is scanned and signed, and only then does it reach a registry. The pipeline is where the build, scan, sign, verify chain from the supply-chain world actually executes — so a pull request that would ship a vulnerable or unsigned image fails before merge, not in production.

A container pipeline stage by stage
1build
pinned, multi-stage
2test
in the built image
3scan
Trivy, fail on critical
4sign + push
Cosign, then registry
Each stage gates the next. The image that reaches the registry has been tested, scanned, and signed — by construction.
.gitlab-ci.yml
build:
stage: build
script:
- docker build -t "$IMG:$CI_COMMIT_SHA" . # tag by commit SHA
- docker save "$IMG:$CI_COMMIT_SHA" -o image.tar # hand off to later stages
artifacts: { paths: [image.tar] }
scan:
stage: test
image: { name: aquasec/trivy:0.53.0, entrypoint: [""] } # pin the scanner
script:
- trivy image --input image.tar --exit-code 0 --severity HIGH
- trivy image --input image.tar --exit-code 1 --severity CRITICAL --ignore-unfixed

Building inside CI: DinD vs the socket

A CI job needs a Docker engine to build. Two common approaches: Docker-in-Docker (a dind service container running its own daemon, isolated per job) or bind-mounting the host’s /var/run/docker.sock into the job. The socket is faster and simpler but is root-equivalent access to the host — a malicious build could take over the runner. Prefer DinD, or a daemonless builder like BuildKit/buildx or Kaniko that builds images without a privileged daemon at all.

.gitlab-ci.yml
build:
image: docker:27
services: ["docker:27-dind"] # isolated daemon per job (safer than the socket)
variables: { DOCKER_TLS_CERTDIR: "/certs" }
script:
- docker build -t "$IMG:$CI_COMMIT_SHA" .
# daemonless alternative: gcr.io/kaniko-project/executor — no privileged daemon needed

Tag by commit, sign, then push

Tag the image with the commit SHA (immutable, traceable to source) rather than a floating name, sign it with Cosign so downstream admission control can verify provenance, and push only after tests and scans pass. The registry then holds a set of images each tied to a commit, each scanned and signed — the input a Kubernetes cluster’s signature-verification policy expects.

.gitlab-ci.yml
sign_push:
stage: deploy
script:
- echo "$REGISTRY_TOKEN" | docker login "$CI_REGISTRY" -u ci --password-stdin
- docker push "$IMG:$CI_COMMIT_SHA"
- cosign sign --yes "$IMG@$(docker inspect --format='{{index .RepoDigests 0}}' "$IMG:$CI_COMMIT_SHA" | cut -d@ -f2)"
rules: [{ if: '$CI_COMMIT_BRANCH == "main"' }]
The Docker socket in CI is root on the runner
Bind-mounting /var/run/docker.sock into a CI job is convenient and dangerous: a compromised or malicious build gets root-equivalent control of the runner and every other job on it. Prefer isolated Docker-in-Docker or a daemonless builder (BuildKit, Kaniko), keep registry credentials in masked CI secrets, and never echo them into logs.