Skip to main content
Back to blog

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.

2 min read

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:

  1. PHP lint / static analysisphpcs against WordPress coding standards, plus PHPStan for the parts of the codebase that aren't fighting WordPress's dynamic patterns too hard.
  2. Automated tests — PHPUnit for custom plugins/themes, using wp-env or a Dockerized WordPress test suite.
  3. Build step — compiling and minifying frontend assets (this is where most WordPress themes still skip a real build pipeline entirely).
  4. Container build — building the production PHP image so a broken Dockerfile fails 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/php

Database-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 nginx

The 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.

What actually changes — and what doesn't — when you move a content-heavy WordPress site to a headless setup with a REST/GraphQL frontend.

Practical techniques for keeping WooCommerce fast under real traffic — from database indexing and object caching to queue-based order processing.

Where PHP applications actually lose time in production, and the concrete fixes I reach for before considering a rewrite.