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 leveraging cached layers from a previous build to speed this up), runs the application's test suite inside a container built from that same image (or an intermediate build stage) to ensure tests run in an environment matching production, tags the image (often with the git commit SHA for traceability), pushes it to a registry, and — for genuine reproducibility — records/pins the resulting digest for use by 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 actually run in production. This is different from running tests directly on the CI runner's own environment, separately from the image build. Doing it this way closes the exact "works on my machine/CI, breaks in production" gap that containers exist to solve in the first place (see the fundamentals topic). Running tests against a different environment than what's actually shipped defeats much of the purpose 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 version tag like latest or even 1.0) gives an unambiguous, traceable link between a specific running image and the exact source code that built it. This is essential for debugging ("which commit is actually deployed right now") and for the digest-pinning practices covered in the registries topic.

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 own machine, which accumulates cache across many local builds. Without addressing this, every single CI build is effectively a full, uncached rebuild, however well the Dockerfile itself is ordered for caching (see that question). Two techniques let CI builds benefit from layer caching despite starting from a clean runner environment each time: pulling a previous build's image as an explicit cache source (--cache-from), or using BuildKit's remote cache export/import capability (see that question).

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, test frameworks, etc.) while the final production stage only copies out the built artifacts. This combines the "test in the real build environment" benefit with the "final shipped image stays minimal" benefit from the multi-stage builds question, in one Dockerfile.

Security scanning as a CI gate

As covered in the registries topic's vulnerability-scanning question, integrating a scan step that can fail the build on critical/high findings prevents a genuinely dangerous image from ever reaching a registry or a deployment target. This catches the issue as early in the pipeline as practical.