PHP and Docker in CI/CD: Building a Reproducible Development and Testing Environment from Scratch
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.exampleKey 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 appuserSecurity Considerations
- Never run a container as
rootin production — useUSER appuser. - Do not copy
.envinto the image — pass variables through the runtime environment. - Add a
.dockerignoreto excludenode_modules,.git, and tests from the production image. - Regularly scan images using
docker scout cvesor 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: bridgePay 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=maxDocker 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-cmdParallel 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
.envto 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 --forceBuild 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 themainbranch.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
developmentstage 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 upin CI without explicit commands. Always target specific services and commands — don't spin up the entire stack. - Missing
.dockerignore. Without it, the build context includesnode_modules(hundreds of MB),.git, and test data. - Storing secrets in image environment variables (
ENV). They are visible viadocker 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_VERSIONvariable.
PHP CI/CD with Docker: Readiness Checklist
- The Dockerfile uses a multi-stage build with separate
developmentandproductionstages. - The base image is pinned to a minor version (e.g.,
php:8.3.10-fpm-alpine3.19). - The container does not run as
root; an unprivileged user has been created. - The
.dockerignorefile excludes.git,node_modules,.env, and test files. - Docker Compose is configured with
healthcheckfor all stateful services (DB, Redis). - The CI pipeline includes: lint → test → build → push.
- Layer caching is configured via
type=ghaindocker/build-push-action. - Dependencies (composer.json) are copied in a separate layer before the source code.
- Secrets are passed at runtime via environment variables, not via
ENVin Dockerfile or build args. - Images are tagged with commit SHA and semantic version;
latestis not used for production deployments. - Rollback is documented and tested: change tag + restart service.
- 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 →