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
# 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/uploadson 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 withdocker history, it is in the image and it is compromised. - Cron: disable WP-Cron (
DISABLE_WP_CRON) and runwp cron event run --due-nowfrom 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
| Symptom | Cause | Fix |
|---|---|---|
| File permission errors on upload | UID mismatch between host and www-data (33) | Run CLI and the app as UID 33; chown volumes once |
| Site URL wrong behind a load balancer | WordPress sees http from the LB | Set $_SERVER['HTTPS']='on' when X-Forwarded-Proto is https in WORDPRESS_CONFIG_EXTRA |
| Changes not showing after deploy | opcache.validate_timestamps=0 with a hot-swapped file | Never edit files in a running container; redeploy |
| Slow local page loads on macOS | Whole tree bind-mounted | Named volume for core, bind only theme/plugins |
| Plugin updates "succeed" then vanish | Code is in the image; container restarts reset it | DISALLOW_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.