Onboarding a new developer to an agency WordPress project used to take a day: install PHP, match the extensions, set up MySQL, import a database, fix the three things that were different on their laptop. Now it takes git clone && docker compose up and about four minutes. The same image, built differently, runs production. This is the setup I use at UMM Digital for client sites.

Part 1: Local development stack

docker compose (dev) ┌──────────┐ :8080 ┌────────────┐ fastcgi ┌───────────────────┐ │ browser │──────────▶ │ nginx │──────────▶ │ wordpress (php-fpm)│ └──────────┘ └────────────┘ │ ./wp-content/ │◀── bind mount: themes/, plugins/ └─────────┬─────────┘ ┌─────────────────┼─────────────────┐ ┌────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ │ mysql 8 │ │ redis 7 │ │ mailpit │ (catches outgoing mail) └──────────┘ └───────────┘ └───────────┘
# compose.yaml
services:
  wordpress:
    image: wordpress:php8.3-fpm-alpine
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: wp
      WORDPRESS_DB_PASSWORD: wp
      WORDPRESS_DB_NAME: wp
      WORDPRESS_CONFIG_EXTRA: |
        define('WP_REDIS_HOST', 'redis');
        define('WP_DEBUG', true);
        define('WP_DEBUG_LOG', true);
        define('WP_ENVIRONMENT_TYPE', 'development');
        define('DISALLOW_FILE_EDIT', true);
    volumes:
      - wp_core:/var/www/html                                   # named volume: core stays inside Docker (fast)
      - ./wp-content/themes/umm:/var/www/html/wp-content/themes/umm         # bind mounts: only what you edit
      - ./wp-content/plugins/umm-blocks:/var/www/html/wp-content/plugins/umm-blocks
      - ./docker/php/dev.ini:/usr/local/etc/php/conf.d/zz-dev.ini
    depends_on: [db, redis]

  nginx:
    image: nginx:1.27-alpine
    ports: ["8080:80"]
    volumes:
      - wp_core:/var/www/html:ro
      - ./wp-content/themes/umm:/var/www/html/wp-content/themes/umm:ro
      - ./wp-content/plugins/umm-blocks:/var/www/html/wp-content/plugins/umm-blocks:ro
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on: [wordpress]

  db:
    image: mysql:8.4
    command: --mysql-native-password=ON
    environment: { MYSQL_DATABASE: wp, MYSQL_USER: wp, MYSQL_PASSWORD: wp, MYSQL_ROOT_PASSWORD: root }
    volumes:
      - db_data:/var/lib/mysql
      - ./docker/db/seed.sql.gz:/docker-entrypoint-initdb.d/seed.sql.gz:ro   # imported on first start
    healthcheck: { test: ["CMD", "mysqladmin", "ping", "-h", "localhost"], interval: 5s, retries: 10 }

  redis:
    image: redis:7-alpine

  mailpit:
    image: axllent/mailpit
    ports: ["8025:8025"]                # web UI to read every email WordPress sends

  cli:
    image: wordpress:cli-php8.3
    user: "33:33"                        # www-data, so files it creates are readable by php-fpm
    volumes_from: [wordpress]
    environment: { WORDPRESS_DB_HOST: db, WORDPRESS_DB_USER: wp, WORDPRESS_DB_PASSWORD: wp, WORDPRESS_DB_NAME: wp }
    depends_on: { db: { condition: service_healthy } }
    profiles: [tools]                    # only runs when asked: docker compose run --rm cli wp ...

volumes:
  wp_core:
  db_data:
# docker/nginx/default.conf
server {
    listen 80;
    root /var/www/html;
    index index.php;
    client_max_body_size 64m;

    location / { try_files $uri $uri/ /index.php?$args; }

    location ~ \.php$ {
        fastcgi_pass wordpress:9000;                 # the php-fpm service name
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_read_timeout 300;
    }

    location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|woff2?)$ { expires 7d; access_log off; }
    location ~ /\.(ht|git) { deny all; }
}

Why the bind-mount rule matters: on macOS and Windows, Docker file sharing is slow, and mounting the whole WordPress tree means every one of the thousands of core files goes through that layer on each request. Keeping core in a named volume and mounting only your theme and plugins makes local page loads roughly 5x faster in my measurements. Use the cli service for WP-CLI tasks:

docker compose up -d
docker compose run --rm cli wp core install --url=http://localhost:8080 --title=Dev --admin_user=admin --admin_password=admin --admin_email=dev@example.com
docker compose run --rm cli wp plugin install redis-cache --activate && docker compose run --rm cli wp redis enable
docker compose run --rm cli wp search-replace 'https://client-site.com' 'http://localhost:8080' --skip-columns=guid   # after importing a prod DB

Part 2: The production image

Production is different in three ways: no bind mounts (code is baked into the image), no dev tools (no Composer, no Node), and a locked-down filesystem. A multi-stage Dockerfile builds assets in throwaway stages and copies only the results into a small runtime image.

# Dockerfile
# ---------- Stage 1: PHP dependencies (Composer) ----------
FROM composer:2 AS vendor
WORKDIR /app
COPY wp-content/themes/umm/composer.json wp-content/themes/umm/composer.lock ./
RUN composer install --no-dev --prefer-dist --no-scripts --no-interaction --optimize-autoloader

# ---------- Stage 2: Front-end assets (Vite + Tailwind) ----------
FROM node:22-alpine AS assets
WORKDIR /app
COPY wp-content/themes/umm/package.json wp-content/themes/umm/package-lock.json ./
RUN npm ci
COPY wp-content/themes/umm/src ./src
COPY wp-content/themes/umm/vite.config.js ./
RUN npm run build                                   # → /app/dist with hashed files + manifest

# ---------- Stage 3: Runtime ----------
FROM wordpress:php8.3-fpm-alpine AS runtime

# Extra PHP extensions + opcache tuned for production
RUN apk add --no-cache icu-dev libzip-dev \
 && docker-php-ext-install intl zip opcache \
 && docker-php-ext-enable opcache
COPY docker/php/prod.ini /usr/local/etc/php/conf.d/zz-prod.ini
COPY docker/php/www.conf  /usr/local/etc/php-fpm.d/www.conf

WORKDIR /var/www/html
# Theme + plugins (the only code we own); core comes from the base image at container start
COPY --chown=www-data:www-data wp-content/themes/umm   /usr/src/wordpress/wp-content/themes/umm
COPY --chown=www-data:www-data wp-content/plugins/     /usr/src/wordpress/wp-content/plugins/
COPY --from=vendor --chown=www-data:www-data /app/vendor /usr/src/wordpress/wp-content/themes/umm/vendor
COPY --from=assets --chown=www-data:www-data /app/dist   /usr/src/wordpress/wp-content/themes/umm/dist

# Hardening: no file edits from wp-admin, no writable code
ENV WORDPRESS_CONFIG_EXTRA="define('DISALLOW_FILE_MODS', true); define('WP_ENVIRONMENT_TYPE', 'production');"
USER www-data
EXPOSE 9000
HEALTHCHECK --interval=30s --timeout=3s CMD php-fpm-healthcheck || exit 1
; docker/php/prod.ini
opcache.enable=1
opcache.memory_consumption=192
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0        ; code never changes inside a running container: skip stat() calls
opcache.jit=tracing
opcache.jit_buffer_size=64M
memory_limit=256M
upload_max_filesize=64M
post_max_size=64M
expose_php=Off

opcache.validate_timestamps=0 is the single biggest PHP performance setting in containers and it is only safe because deploys replace the container rather than the files. The PHP-FPM pool sizing (www.conf) and the Nginx FastCGI cache in front are covered in Nginx Performance Tuning for PHP.

Image size and layer caching

Copying package.json and composer.json before the source means dependency layers are cached until the lockfiles change, so a CSS tweak rebuilds in about 20 seconds instead of four minutes. The final image is around 180 MB versus 550 MB for a naive single-stage build with Node and Composer left inside.

Part 3: State that must live outside the container

  • Uploads: wp-content/uploads on a persistent volume (EFS, a block volume) or, better, offloaded to S3 with a plugin so containers are fully disposable and can scale horizontally.
  • Database: managed MySQL (RDS, PlanetScale, DigitalOcean) rather than a container. Backups, failover and upgrades are someone else's job.
  • Object cache: managed Redis. See Redis Caching Strategies for the drop-in configuration.
  • Secrets: WORDPRESS_DB_PASSWORD, salts and API keys injected as environment variables from the platform's secrets store. If you can see a password with docker history, it is in the image and it is compromised.
  • Cron: disable WP-Cron (DISABLE_WP_CRON) and run wp cron event run --due-now from a scheduled task every minute, so a spike of traffic does not trigger a spike of cron jobs.

Part 4: Shipping it

The image is built once in CI, tagged with the git SHA, pushed to a registry and deployed by replacing the running container. That pipeline, with OIDC to AWS and a rollback step, is the subject of CI/CD with GitHub Actions. For a single VPS, the deploy is a docker compose pull && docker compose up -d with a production override file that removes the dev-only services and mounts:

# compose.prod.yaml  (docker compose -f compose.yaml -f compose.prod.yaml up -d)
services:
  wordpress:
    image: ghcr.io/umm-digital/client-site:${GIT_SHA}
    volumes:
      - uploads:/var/www/html/wp-content/uploads
    environment:
      WORDPRESS_DB_HOST: ${DB_HOST}
      WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
      WORDPRESS_CONFIG_EXTRA: |
        define('WP_REDIS_HOST', '${REDIS_HOST}');
        define('DISABLE_WP_CRON', true);
    restart: unless-stopped
  nginx:
    ports: ["80:80"]
    volumes:
      - uploads:/var/www/html/wp-content/uploads:ro
      - ./docker/nginx/prod.conf:/etc/nginx/conf.d/default.conf:ro
    restart: unless-stopped
  db: { profiles: [never] }          # managed DB in prod: disable the container
  mailpit: { profiles: [never] }
volumes:
  uploads:

Gotchas from real deployments

SymptomCauseFix
File permission errors on uploadUID mismatch between host and www-data (33)Run CLI and the app as UID 33; chown volumes once
Site URL wrong behind a load balancerWordPress sees http from the LBSet $_SERVER['HTTPS']='on' when X-Forwarded-Proto is https in WORDPRESS_CONFIG_EXTRA
Changes not showing after deployopcache.validate_timestamps=0 with a hot-swapped fileNever edit files in a running container; redeploy
Slow local page loads on macOSWhole tree bind-mountedNamed volume for core, bind only theme/plugins
Plugin updates "succeed" then vanishCode is in the image; container restarts reset itDISALLOW_FILE_MODS; manage plugins in git and rebuild

The mindset shift is the last row: in containers, code lives in git and images, not on the server. Once a team internalises that, WordPress becomes as reproducible as any Node service, and the "which version is live?" question always has an answer: the git SHA in the image tag.