How do you use Docker in a CI/CD pipeline to build and test images?

7 minadvancedci-cddocker-build-pipeline

Quick Answer

A typical pipeline builds the image from the Dockerfile, often reusing cached layers from a previous build to speed this up, then runs the application's test suite inside a container built from that same image (or an intermediate build stage) so tests run in an environment matching production. It tags the image, often with the git commit SHA for traceability, pushes it to a registry, and, for genuine reproducibility, records or pins the resulting digest for the actual deployment step.

Detailed Answer

A representative pipeline sequence

# Simplified CI pipeline concept
steps:
  - name: Build image
    run: docker build -t myapp:${{ github.sha }} .

  - name: Run tests inside a container
    run: docker run --rm myapp:${{ github.sha }} npm test

  - name: Scan for vulnerabilities
    run: trivy image --exit-code 1 --severity CRITICAL myapp:${{ github.sha }}

  - name: Push to registry
    run: |
      docker tag myapp:${{ github.sha }} myregistry.example.com/myapp:${{ github.sha }}
      docker push myregistry.example.com/myapp:${{ github.sha }}

Why building the image is often the very first step, before tests even run

Building the actual production image first, then running tests inside a container built from that image, ensures the tests genuinely validate the same environment that will run in production. That's different from running tests directly on the CI runner's own environment, separately from the image build. This closes the exact "works on my machine/CI, breaks in production" gap containers exist to solve. Running tests against a different environment than what actually ships defeats much of the point of containerizing the application at all.

Tagging with the commit SHA for traceability

docker build -t myapp:${{ github.sha }} .

Tagging each CI-built image with the specific git commit SHA that produced it, rather than only a generic tag like latest or 1.0, gives an unambiguous, traceable link between a running image and the exact source code that built it. This is essential for debugging — it lets you answer exactly which commit is deployed right now — and it supports digest-pinning practices used when promoting images between environments.

Leveraging build cache across CI runs

- name: Build with registry cache
  run: |
    docker build \
      --cache-from myregistry.example.com/myapp:latest \
      -t myapp:${{ github.sha }} .

A fresh CI runner typically starts with no local build cache at all, unlike a developer's machine, which accumulates cache across many local builds. Without addressing this, every CI build is effectively a full, uncached rebuild, however well the Dockerfile itself is ordered for caching. Two techniques let CI builds benefit from layer caching despite starting from a clean runner each time: pulling a previous build's image as an explicit cache source with --cache-from, or using BuildKit's remote cache export/import capability.

Multi-stage builds work especially well in CI

FROM node:20 AS test
WORKDIR /app
COPY . .
RUN npm ci && npm test

FROM node:20-slim AS production
WORKDIR /app
COPY --from=test /app/dist ./dist
CMD ["node", "dist/server.js"]

A dedicated test stage can run the full test suite with all dev dependencies and test frameworks installed, while the final production stage only copies out the built artifacts. This combines "test in the real build environment" with "final shipped image stays minimal," both from multi-stage builds, in one Dockerfile.

Security scanning as a CI gate

Adding a vulnerability-scanning step that can fail the build on critical or high findings stops a genuinely dangerous image from ever reaching a registry or deployment target. It catches the issue as early in the pipeline as practical.