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: trueloads 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-actionauthenticates to GHCR with the workflow'sGITHUB_TOKEN: no personal token to create, you only need to grantpackages: writeto the job.docker/metadata-actiongenerates the tags:type=shaproduces a tag likesha-1a2b3c4, andlatestis 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.localfile or a key into the image, and do not pass secrets throughARG, 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.