Building CI/CD Pipelines for WordPress with Docker
A practical WordPress deployment pipeline: Docker for environment parity, automated checks in CI, and zero-downtime releases.
WordPress has a reputation for manual, FTP-based deployments — and a lot of agencies still work that way. It doesn't have to be that way. Here's the pipeline I use to bring WordPress projects up to the same deployment standard as any other application.
Environment parity with Docker #
The root cause of most "works on my machine" WordPress bugs is a PHP version, extension,
or MySQL version mismatch between local, staging, and production. A docker-compose.yml
that's shared across all three removes that variable entirely:
services:
wordpress:
build: ./docker/php
volumes:
- ./:/var/www/html
depends_on:
- db
db:
image: mysql:8.0
environment:
MYSQL_DATABASE: wordpress
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
nginx:
image: nginx:stable
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
ports:
- "8080:80"Every developer, the CI runner, and (via the same base image) production run the exact same PHP version and extension set.
What actually belongs in CI #
For a WordPress project, I run four checks on every pull request:
- PHP lint / static analysis —
phpcsagainst WordPress coding standards, plus PHPStan for the parts of the codebase that aren't fighting WordPress's dynamic patterns too hard. - Automated tests — PHPUnit for custom plugins/themes, using
wp-envor a Dockerized WordPress test suite. - Build step — compiling and minifying frontend assets (this is where most WordPress themes still skip a real build pipeline entirely).
- Container build — building the production PHP image so a broken
Dockerfilefails in CI, not on deploy.
# .github/workflows/ci.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: composer install --no-progress
- run: vendor/bin/phpcs --standard=WordPress .
- run: vendor/bin/phpunit
- run: docker build -t app:ci ./docker/phpDatabase-dependent tests
Spin up MySQL as a CI service container rather than mocking the database for WordPress-specific tests — WordPress's data layer is tightly coupled to actual MySQL behavior (autoincrement IDs, specific SQL functions), and mocks tend to pass while the real thing fails.
Zero-downtime deploys #
For self-hosted (non-managed) WordPress, I deploy with a symlink swap rather than overwriting files in place:
RELEASE_DIR="/var/www/releases/$(date +%Y%m%d%H%M%S)"
mkdir -p "$RELEASE_DIR"
rsync -a --exclude='.git' ./ "$RELEASE_DIR"
ln -sfn "$RELEASE_DIR" /var/www/current
sudo systemctl reload php-fpm nginxThe symlink swap is atomic from Nginx's perspective — there's no window where the document root contains a half-deployed codebase. Database migrations (for custom plugins) run as a separate, explicit step before the symlink swap, so a failed migration blocks the release instead of shipping a broken site.
Lessons learned #
The biggest win isn't any individual step — it's that once WordPress deploys go through the same CI/CD discipline as any other application, "it broke in production" incidents drop sharply, because the failure modes get caught in a pull request instead of after a manual FTP upload.