Building CI Pipelines with Docker
This guide moves from manual testing strategies to automated pipelines. We will cover how to configure the two most popular CI platforms—GitHub Actions and GitLab CI—to build, test, and publish your Docker images automatically on every commit.
The Core Challenge: Running Docker Inside CI
CI runners are usually virtual machines or containers themselves. To build and run Docker images inside them, we need a strategy.
Strategy 1: Docker-in-Docker (DinD)
What it is: Running a full Docker daemon inside the CI container.
Pros: Complete isolation. You can start/stop the daemon without affecting the host.
Cons: Slow startup (needs to boot the daemon). Requires
--privilegedmode, which has security risks.Use Case: GitLab CI often uses this pattern.
Strategy 2: Docker-out-of-Docker (DooD) / Socket Binding
What it is: Mounting the host's Docker socket (
/var/run/docker.sock) into the CI container.Pros: Extremely fast (uses the host's daemon). Shared image cache with the host.
Cons: No isolation (if you kill the daemon, you kill the host's Docker). Potential naming conflicts if multiple jobs run on the same host.
Use Case: GitHub Actions and local Jenkins agents use this pattern.
1. GitHub Actions Workflow
GitHub Actions uses the "Socket Binding" approach by default. The ubuntu-latest runner comes with Docker installed, and your steps run directly on the host.
Key Action: docker/build-push-action This is the official, powerful action from Docker. It handles building, caching, tagging, and pushing multi-arch images using buildx.
Example: Build, Test, and Push
Create .github/workflows/docker-ci.yml:
name: Docker CI
on:
push:
branches: [ "main" ]
tags: [ "v*.*.*" ]
pull_request:
branches: [ "main" ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-test:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
# 1. Run Tests (using the Docker Compose strategy from File 14)
- name: Run Integration Tests
run: |
docker compose -f docker-compose.test.yml up --abort-on-container-exit --exit-code-from sut
# 2. Set up Docker Buildx (required for caching and multi-arch)
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# 3. Log in to the Registry (only on push, not PR)
- name: Log in to the Container registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# 4. Extract Metadata (tags, labels)
# Automatically generates tags like 'main', 'v1.0.0', 'pr-123'
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# 5. Build and Push
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max2. GitLab CI Workflow
GitLab CI typically uses the Docker-in-Docker (DinD) service pattern. You define a job that uses a Docker image (the client) and a "service" (the daemon).
Example: .gitlab-ci.yml
stages:
- test
- build
variables:
# Use the overlay2 driver for better performance
DOCKER_DRIVER: overlay2
# Disable TLS for easier DinD setup (safe inside the runner)
DOCKER_TLS_CERTDIR: ""
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
# --- Stage 1: Test ---
integration_test:
stage: test
image: docker/compose:latest
services:
- docker:dind
script:
- docker compose -f docker-compose.test.yml up --abort-on-container-exit --exit-code-from sut
# --- Stage 2: Build & Push ---
build_image:
stage: build
image: docker:latest
services:
- docker:dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
# Build the image
- docker build -t $IMAGE_TAG .
# Push the image
- docker push $IMAGE_TAG
# If this is a tag (release), verify and push 'latest' as well
- |
if [[ "$CI_COMMIT_TAG" != "" ]]; then
docker tag $IMAGE_TAG $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
fi
only:
- main
- tags3. Optimizing Pipeline Speed
Docker builds can be slow. Caching is the #1 way to speed them up.
Layer Caching
As discussed in File 07 (COPY package.json before COPY .), Docker uses layer caching.
In GitHub Actions: The
docker/build-push-actionsupports agha(GitHub Actions) cache backend. It saves your image layers to the GitHub Actions cache, making subsequent builds blazing fast.cache-from: type=gha cache-to: type=gha,mode=max
Registry Caching (Inline Cache)
You can also embed cache metadata directly into the image you push to the registry.
docker buildx build \
--cache-to type=inline \
--cache-from type=registry,ref=myuser/myapp:build-cache \
...This allows the CI runner to pull cache layers from the registry if they exist, speeding up the build even on a fresh runner.
4. Security in Pipelines
Secrets: NEVER put secrets in your
docker buildcommand orDockerfile. Pass them as build arguments (--build-arg) ONLY if absolutely necessary, but prefer injecting them at runtime.Least Privilege: In GitHub Actions, use the
permissions:block (as shown above) to grant only the necessary access (e.g.,packages: write) rather than a full admin token.Scan on Push: Add a step (like the Trivy example in File 14) before the
docker pushstep. If the scan fails, do not push the vulnerable image.
