DevOps

PHP and Docker in CI/CD: Building a Reproducible Development and Testing Environment from Scratch

Ruslan Ismailov Published 12 min read
P

Introduction: The "Works on My Machine" Problem in 2026

"It works on my machine" — a phrase that still costs teams weeks of debugging. Different versions of PHP, extensions, nginx configurations, and environment variables between a developer's laptop, staging, and production create bugs that are nearly impossible to reproduce. In 2026, when release cycles have shrunk from days to hours, environment reproducibility is not optional — it's a survival requirement for any team.

Docker solves this problem at the infrastructure level: the same image runs on a developer's MacBook, in GitHub Actions, and on a production server. Combined with a CI/CD pipeline, this delivers a fully automated path from commit to deploy with a guaranteed identical environment at every stage. This article is a practical guide: no abstractions, just real files and commands.

Basic Docker Environment Structure for a PHP Project

A typical PHP project requires at least three containers: the application (php-fpm), a web server (nginx), and a database. The directory structure sets the tone for the entire project:

project/
├── docker/
│   ├── php/
│   │   ├── Dockerfile
│   │   └── php.ini
│   └── nginx/
│       └── default.conf
├── src/          # PHP source code
├── docker-compose.yml
├── docker-compose.override.yml  # local overrides
└── .env.example

Key organizational principles:

  • One process — one container. php-fpm doesn't handle routing; nginx doesn't execute PHP.
  • Volumes for development only. In a production image, code must be baked in.
  • Shared network. All services reside on the same Docker network for isolated communication.

Writing a Proper Dockerfile for PHP

Choosing a Base Image

In 2026, the recommended base is php:8.3-fpm-alpine. Alpine provides a minimal image size (~30 MB vs. ~400 MB for Debian variants) and a smaller attack surface. For production, avoid latest tags — pin to a minor version.

# docker/php/Dockerfile
FROM php:8.3-fpm-alpine3.19 AS base

# System dependencies
RUN apk add --no-cache \
    git \
    curl \
    libpng-dev \
    libzip-dev \
    icu-dev \
    oniguruma-dev \
    && docker-php-ext-install \
        pdo_mysql \
        mbstring \
        zip \
        gd \
        intl \
        opcache

# Install Composer
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer

# PHP settings
COPY docker/php/php.ini /usr/local/etc/php/conf.d/custom.ini

# Security: do not run as root
RUN addgroup -g 1000 appgroup && adduser -u 1000 -G appgroup -s /bin/sh -D appuser

# Production stage
FROM base AS production
WORKDIR /var/www/html
COPY --chown=appuser:appgroup src/ .
RUN composer install --no-dev --optimize-autoloader --no-interaction
USER appuser

# Development stage
FROM base AS development
RUN apk add --no-cache $PHPIZE_DEPS \
    && pecl install xdebug \
    && docker-php-ext-enable xdebug
WORKDIR /var/www/html
USER appuser

Security Considerations

  • Never run a container as root in production — use USER appuser.
  • Do not copy .env into the image — pass variables through the runtime environment.
  • Add a .dockerignore to exclude node_modules, .git, and tests from the production image.
  • Regularly scan images using docker scout cves or Trivy.

Docker Compose for Local Development

# docker-compose.yml
services:
  php:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
      target: development
    volumes:
      - ./src:/var/www/html
      - composer_cache:/root/.composer
    environment:
      APP_ENV: local
      DB_HOST: db
      DB_PORT: 3306
      DB_DATABASE: ${DB_DATABASE}
      DB_USERNAME: ${DB_USERNAME}
      DB_PASSWORD: ${DB_PASSWORD}
    depends_on:
      db:
        condition: service_healthy
    networks:
      - app_network

  nginx:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"
    volumes:
      - ./src:/var/www/html:ro
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - php
    networks:
      - app_network

  db:
    image: mysql:8.4
    environment:
      MYSQL_DATABASE: ${DB_DATABASE}
      MYSQL_USER: ${DB_USERNAME}
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - app_network

volumes:
  db_data:
  composer_cache:

networks:
  app_network:
    driver: bridge

Pay attention to the healthcheck for the database. Without it, the PHP container starts before MySQL is ready to accept connections, causing the application to crash on startup. The condition: service_healthy option in depends_on resolves this at the Docker Compose level.

CI/CD Integration: A GitHub Actions Pipeline

GitHub Actions is the de facto standard for PHP Docker CI/CD in open-source and most commercial projects. The pipeline consists of four stages: linting, testing, image build, and publishing to a registry.

# .github/workflows/ci.yml
name: PHP CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  lint:
    name: Code Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: PHP CS Fixer
        run: |
          docker run --rm \
            -v ${{ github.workspace }}/src:/app \
            cytopia/php-cs-fixer:3 fix --dry-run --diff /app

  test:
    name: Unit & Integration Tests
    runs-on: ubuntu-latest
    needs: lint
    services:
      db:
        image: mysql:8.4
        env:
          MYSQL_DATABASE: test_db
          MYSQL_USER: test_user
          MYSQL_PASSWORD: test_pass
          MYSQL_ROOT_PASSWORD: root_pass
        ports:
          - 3306:3306
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build test image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: docker/php/Dockerfile
          target: development
          load: true
          tags: php-app:test
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Run PHPUnit
        run: |
          docker run --rm \
            --network host \
            -e APP_ENV=testing \
            -e DB_HOST=127.0.0.1 \
            -e DB_DATABASE=test_db \
            -e DB_USERNAME=test_user \
            -e DB_PASSWORD=test_pass \
            php-app:test \
            ./vendor/bin/phpunit --coverage-text

  build-and-push:
    name: Build & Push Image
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=semver,pattern={{version}}
            type=sha,prefix=sha-
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: docker/php/Dockerfile
          target: production
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Docker Layer Caching in CI

Without caching, every CI build re-downloads all dependencies — that's 3–5 minutes of wasted time per push. GitHub Actions offers two primary approaches:

  • GitHub Actions Cache (type=gha) — a built-in mechanism that requires no third-party services. Recommended for most projects.
  • Registry Cache (type=registry) — stores the cache directly in the container registry. Suitable for self-hosted runners without a shared cache.

The key strategy for PHP is the correct layer order in the Dockerfile. Copy composer.json and composer.lock first, install dependencies, and only then copy the source code. This ensures the vendor/ layer is only invalidated when dependencies change, not on every code change.

# Optimized Dockerfile section for caching
WORKDIR /var/www/html

# Dependency files first
COPY src/composer.json src/composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-scripts --no-interaction

# Then all source code (this layer is invalidated more frequently)
COPY src/ .
RUN composer run-script post-install-cmd

Parallel Test Execution in Docker Within the Pipeline

For large test suites (1000+ tests), sequential execution becomes a bottleneck. GitHub Actions allows you to split tests into groups using a matrix strategy:

  test-parallel:
    name: Tests (shard ${{ matrix.shard }}/${{ matrix.total }})
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
        total: [4]
    steps:
      - uses: actions/checkout@v4

      - name: Run PHPUnit shard
        run: |
          docker run --rm \
            -e APP_ENV=testing \
            php-app:test \
            ./vendor/bin/phpunit \
              --testsuite=Unit \
              --group=shard${{ matrix.shard }}

An alternative is to use paratest inside a single container to parallelize via processes. For PHP Docker CI/CD, this is often faster than multiple matrix jobs due to container startup overhead.

Managing Environment Variables and Secrets

One of the biggest sources of incidents is secret leakage through Docker images or CI logs. Rules for handling secrets in PHP Docker CI/CD:

  • Never add .env to a Docker image. Add it to .dockerignore.
  • In GitHub Actions, store secrets in Repository Secrets or Environment Secrets (for separate management of staging and production).
  • Pass secrets to containers via -e KEY=${{ secrets.KEY }}, not through build arguments.
  • For complex projects, use HashiCorp Vault or AWS Secrets Manager with dynamic token issuance.
      - name: Run migrations
        run: |
          docker run --rm \
            -e APP_KEY=${{ secrets.APP_KEY }} \
            -e DB_PASSWORD=${{ secrets.DB_PASSWORD }} \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
            php artisan migrate --force

Build secrets (via --secret id=... in Buildkit) allow sensitive data to be passed during image build without it ending up in final layers. Use this mechanism for Composer with private repositories.

Artifacts and Image Versioning

Proper image versioning is the foundation of reliable rollbacks. Recommended tagging strategy:

  • latest — always points to the most recent stable image from the main branch.
  • sha-<commit-hash> — an immutable tag for exact reproduction of any deployment.
  • v1.2.3 — semantic version from a Git tag for releases.
  • develop-<date> — temporary images for testing feature branches.

To roll back in production, simply change the image tag to the previous SHA and restart the service. Never rely solely on latest for rollbacks — it's an anti-pattern.

Common Mistakes and How to Avoid Them

  • Xdebug in the production image. Only include Xdebug in the development stage of the Dockerfile. It reduces performance by 3–5x.
  • Mounting vendor/ via a volume in CI. This breaks layer caching and slows down the pipeline. In CI, dependencies must be inside the image.
  • Using docker-compose up in CI without explicit commands. Always target specific services and commands — don't spin up the entire stack.
  • Missing .dockerignore. Without it, the build context includes node_modules (hundreds of MB), .git, and test data.
  • Storing secrets in image environment variables (ENV). They are visible via docker inspect. Pass secrets only at runtime.
  • Different PHP versions between the local environment and CI. Pin the exact image version in both places and use a single PHP_VERSION variable.

PHP CI/CD with Docker: Readiness Checklist

  1. The Dockerfile uses a multi-stage build with separate development and production stages.
  2. The base image is pinned to a minor version (e.g., php:8.3.10-fpm-alpine3.19).
  3. The container does not run as root; an unprivileged user has been created.
  4. The .dockerignore file excludes .git, node_modules, .env, and test files.
  5. Docker Compose is configured with healthcheck for all stateful services (DB, Redis).
  6. The CI pipeline includes: lint → test → build → push.
  7. Layer caching is configured via type=gha in docker/build-push-action.
  8. Dependencies (composer.json) are copied in a separate layer before the source code.
  9. Secrets are passed at runtime via environment variables, not via ENV in Dockerfile or build args.
  10. Images are tagged with commit SHA and semantic version; latest is not used for production deployments.
  11. Rollback is documented and tested: change tag + restart service.
  12. Parallel test execution is configured for test suites with more than 500 tests.

A reproducible development environment with PHP Docker CI/CD is not a one-time setup — it's a living system. Regularly update base images, monitor CVEs with automated scanners, and review your pipeline whenever the project architecture changes. A team that sets up this infrastructure correctly from the start saves hours with every sprint.

Technologies

Tags

Ruslan Ismailov

Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →