◀ Back to blog
Docker

Docker in CI/CD pipelines

Published on 05 Apr 2024· 9 min read
#Docker#CI/CD#DevOps

Docker and continuous integration

Docker has become essential in modern CI/CD pipelines. It guarantees consistency between test and production environments.

The principle is simple: instead of rebuilding the environment on every CI machine (PHP version, extensions, system dependencies), you build an image once, test it, then deploy exactly that image. The famous "it works on my machine" disappears, because the machine is now part of the delivered artifact. The pipeline also becomes independent of the CI provider: the same docker build and docker run commands work on GitHub Actions, GitLab CI or Jenkins.

A multi-stage Dockerfile

Everything relies on a multi-stage Dockerfile (multi-stage build): a shared base stage, a test target that contains the development dependencies, and a slimmed-down production target. The pipeline picks the target with the --target option.

# syntax=docker/dockerfile:1
FROM php:8.3-fpm-alpine AS base
RUN apk add --no-cache icu-libs libzip \
    && apk add --no-cache --virtual .build-deps $PHPIZE_DEPS icu-dev libzip-dev \
    && docker-php-ext-install intl zip pdo_mysql opcache \
    && apk del .build-deps
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/app

FROM base AS vendor
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-progress

FROM base AS test
ENV APP_ENV=test
COPY composer.json composer.lock ./
RUN composer install --no-scripts --no-autoloader --prefer-dist --no-progress
COPY . .
RUN composer dump-autoload

FROM base AS production
ENV APP_ENV=prod
COPY --from=vendor /var/www/app/vendor ./vendor
COPY . .
RUN composer dump-autoload --classmap-authoritative --no-dev \
    && php bin/console cache:warmup \
    && chown -R www-data:www-data var
USER www-data

The order of instructions matters: composer.json and composer.lock are copied before the rest of the code. As long as dependencies do not change, Docker reuses the layer containing vendor/, and only the source code copy is redone. That is what makes subsequent builds fast.

Also remember the .dockerignore file, which avoids sending useless or sensitive files to the build and invalidating the cache on every local change:

.git
vendor
var
node_modules
.env.local
.env.*.local

GitHub Actions pipeline with Docker

Here is a minimal version of the pipeline: one job builds the test image and runs PHPUnit inside it, then, if the tests pass, a second job builds the production image and pushes it to GitHub Container Registry.

name: CI/CD Pipeline
on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build test image
        run: docker build --target test -t app:test .
      - name: Run tests
        run: docker run --rm app:test php bin/phpunit

  build-and-push:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - name: Log in to GitHub Container Registry
        run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
      - name: Build and push production image
        run: |
          IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
          docker build --target production -t "$IMAGE:$GITHUB_SHA" -t "$IMAGE:latest" .
          docker push --all-tags "$IMAGE"

The image name is derived from the repository (ghcr.io/owner/repository), converted to lowercase because registries reject uppercase letters. Authentication uses the workflow's GITHUB_TOKEN, which only needs the packages: write permission. Each image receives two tags: the commit SHA, which tells exactly what is running in production and makes a rollback easy, and latest. Each job still rebuilds everything from scratch, though, because runners do not keep the Docker cache between two runs.

Cache optimization

Docker layer caching is crucial for pipeline speed:

- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3
- name: Build with cache
  uses: docker/build-push-action@v5
  with:
    cache-from: type=gha
    cache-to: type=gha,mode=max

type=gha stores layers in the GitHub Actions cache. With mode=max, layers from intermediate stages (such as vendor) are exported too, not only those of the final image. When several builds share the same cache, give each one its own scope so they do not overwrite each other.

A complete, traceable pipeline

The following version builds on the same principle with the official Docker actions: tags generated by docker/metadata-action, a separate cache per target, tests run with their services thanks to Docker Compose, and pull requests that are tested without publishing anything.

name: CI/CD Pipeline
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - name: Build test image
        uses: docker/build-push-action@v6
        with:
          context: .
          target: test
          tags: app:test
          load: true
          cache-from: type=gha,scope=test
          cache-to: type=gha,scope=test,mode=max
      - name: Run tests
        run: docker compose -f compose.ci.yaml run --rm app php bin/phpunit
      - name: Clean up
        if: always()
        run: docker compose -f compose.ci.yaml down -v

  build-and-push:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha
            type=raw,value=latest,enable={{is_default_branch}}
      - uses: docker/build-push-action@v6
        with:
          context: .
          target: production
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha,scope=production
          cache-to: type=gha,scope=production,mode=max

Key points:

  • load: true loads the test image into the runner's Docker daemon so it can be run afterwards; without this option, Buildx keeps the result in its own cache.
  • docker/login-action authenticates to GHCR with the workflow's GITHUB_TOKEN: no personal token to create, you only need to grant packages: write to the job.
  • docker/metadata-action generates the tags: type=sha produces a tag like sha-1a2b3c4, and latest is only added on the default branch. Every image in production is thus tied to a specific commit, and a rollback means redeploying the previous tag.
  • The publishing job only runs on main: pull requests are tested but publish nothing.

Parallel tests

  • Use Docker Compose to start test services (DB, Redis)
  • Run test suites in parallel in separate containers
  • Clean up resources after each run

The compose.ci.yaml file used above starts the database next to the test image, and only runs the tests once MySQL is actually ready:

services:
  app:
    image: app:test
    depends_on:
      mysql:
        condition: service_healthy
    environment:
      DATABASE_URL: mysql://root:root@mysql:3306/test

  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: test
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]
      interval: 5s
      timeout: 5s
      retries: 10

The service_healthy condition is essential: a plain depends_on only waits for the container to start, not for MySQL to accept connections. To parallelize, a GitHub Actions matrix can start several jobs with the same image, each running a different PHPUnit suite (--testsuite unit, --testsuite integration). The final down -v, executed even on failure thanks to if: always(), removes containers and volumes.

Common pitfalls

  • Secrets in the image: never copy a .env.local file or a key into the image, and do not pass secrets through ARG, which stays visible in the history. Production secrets are injected when the container starts.
  • Test image deployed: without --target production, Docker builds the last stage of the Dockerfile. Make sure it is the one you expect.
  • Uppercase image names: registries require lowercase names, which is a problem if the GitHub organization name contains uppercase letters.
  • Constantly invalidated cache: a COPY . . placed too early, or a missing .dockerignore, makes dependencies rebuild on every commit.

Summary

A multi-stage Dockerfile, a properly configured Buildx cache, images tagged by commit and test services orchestrated by Compose: with these four elements, the pipeline builds once what it tests and deploys.

This strategy cuts deployment time from 30 minutes to under 5 minutes.