Key Takeaways
Containerized WordPress gives you isolated, reproducible environments that eliminate 'works on my machine' problems
Caddy handles reverse proxy and automatic HTTPS with zero configuration for certificate renewal
PHP-FPM in its own container lets you tune process management independently of the web server
MariaDB and Redis in containers with persistent volumes means your data survives container restarts
The complete stack can be deployed to any Linux server with Docker installed in under 10 minutes
Why Docker for WordPress
Most WordPress performance content assumes you're on shared hosting or a managed platform. Install a caching plugin, enable a CDN, hope for the best. That's fine for many sites.
But if you manage WordPress sites for clients, you've hit the limits of that approach. You need consistent environments across staging and production. You need to tune PHP-FPM, MariaDB, and Redis independently. You need deployments that are reproducible, not a set of manual steps you documented in a Google Doc six months ago and forgot to update.
Docker solves all of this. Each component runs in its own container with its own configuration. The entire stack is defined in a single docker-compose.yml file. Spin up a new client site by copying the file and changing a few environment variables. Deploy the same stack to any Linux server with Docker installed.
Almost nobody in the WordPress content space has documented this for production use. The Docker Hub WordPress image is a starting point, but it bundles Apache (not ideal), uses default PHP settings (not tuned), and skips Redis entirely. Here's the stack we actually run.
The architecture
Four containers, each doing one job:
┌─────────────┐
│ Caddy │ ← Reverse proxy, HTTPS, HTTP/3
│ :80/:443 │
└──────┬──────┘
│
┌──────▼──────┐
│ PHP-FPM │ ← WordPress application server
│ :9000 │
└──────┬──────┘
│
┌──────▼──────┐ ┌─────────────┐
│ MariaDB │ │ Redis │
│ :3306 │ │ :6379 │
└─────────────┘ └─────────────┘
- Caddy: handles incoming HTTPS requests, terminates TLS, reverse-proxies to PHP-FPM. Automatic certificate provisioning from Let's Encrypt. Zero-config HTTPS.
- PHP-FPM 8.3: runs WordPress. Tuned process manager, not defaults. No Apache, no Nginx modules, just PHP.
- MariaDB 11: the database. Persistent storage, slow query logging enabled, tuned buffer sizes.
- Redis 7: object cache for WordPress. Caches database query results in memory. Also handles PHP session storage.
The docker-compose.yml
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
- wordpress:/var/www/html:ro
depends_on:
- php
networks:
- frontend
- backend
php:
build:
context: .
dockerfile: Dockerfile.php
restart: unless-stopped
volumes:
- wordpress:/var/www/html
- ./php/custom.ini:/usr/local/etc/php/conf.d/custom.ini:ro
- ./php/www.conf:/usr/local/etc/php-fpm.d/www.conf:ro
environment:
WORDPRESS_DB_HOST: mariadb
WORDPRESS_DB_USER: ${DB_USER}
WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
WORDPRESS_DB_NAME: ${DB_NAME}
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_started
networks:
- backend
mariadb:
image: mariadb:11
restart: unless-stopped
volumes:
- mariadb_data:/var/lib/mysql
- ./mariadb/custom.cnf:/etc/mysql/conf.d/custom.cnf:ro
- mariadb_logs:/var/log/mysql
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backend
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru --save ""
volumes:
- redis_data:/data
networks:
- backend
volumes:
wordpress:
mariadb_data:
mariadb_logs:
caddy_data:
caddy_config:
redis_data:
networks:
frontend:
backend:
internal: trueWhat's going on here
Network isolation: the backend network is marked internal: true. MariaDB and Redis are not accessible from the outside. Only Caddy faces the internet. PHP-FPM communicates with MariaDB and Redis over the internal network.
Health checks: the MariaDB container has a health check. The PHP container waits until MariaDB reports healthy before starting. This prevents WordPress from crashing on startup because the database isn't ready yet.
Redis configuration: maxmemory 128mb sets a hard memory limit. allkeys-lru evicts the least recently used keys when memory is full. save "" disables disk persistence because we're using Redis as a cache, not a data store. If the container restarts, the cache rebuilds from the database.
Persistent volumes: wordpress, mariadb_data, and redis_data are named volumes. They survive container stops, restarts, and rebuilds. Your WordPress files, database, and cache data persist.
The PHP-FPM Dockerfile
FROM wordpress:php8.3-fpm-alpine
# Install Redis extension
RUN apk add --no-cache $PHPIZE_DEPS \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apk del $PHPIZE_DEPS
# Install additional PHP extensions for WooCommerce
RUN docker-php-ext-install opcache
# Clean up
RUN rm -rf /tmp/pearWe start from the official WordPress FPM image (Alpine variant for smaller image size), add the Redis PHP extension, and enable OPcache. That's it. The PHP-FPM configuration lives in a mounted file, not baked into the image.
The Caddyfile
{$DOMAIN} {
root * /var/www/html
php_fastcgi php:9000
file_server
encode gzip zstd
# Security headers
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# Block xmlrpc
respond /xmlrpc.php 403
# Block direct access to PHP files in uploads
@uploads path /wp-content/uploads/*.php
respond @uploads 403
# Cache static assets
@static path *.css *.js *.jpg *.jpeg *.png *.gif *.webp *.avif *.svg *.woff2 *.woff
header @static Cache-Control "public, max-age=31536000, immutable"
# Logs
log {
output file /data/access.log {
roll_size 10MB
roll_keep 5
}
}
}
Why Caddy instead of Nginx
I ran Nginx for years. Caddy replaced it across all client deployments for three reasons:
-
Automatic HTTPS. Caddy provisions Let's Encrypt certificates and renews them without any configuration. No Certbot, no cron jobs, no renewal failures at 3am. Point your DNS at the server, set the domain in the Caddyfile, done.
-
Simpler configuration. The Caddyfile above is the entire config. The equivalent Nginx config is 45-60 lines with separate server blocks, location directives, fastcgi_pass settings, and SSL certificate paths.
-
HTTP/3 by default. Caddy supports HTTP/3 (QUIC) out of the box. Nginx requires compiling from source with the quic patch or using the experimental nginx-quic branch.
Performance in real-world WordPress benchmarks is comparable. Nginx has a slight edge in raw throughput under extreme load (10,000+ concurrent connections). For WordPress sites handling normal traffic, the difference is invisible.
PHP-FPM configuration
The mounted www.conf file:
[www]
user = www-data
group = www-data
listen = 0.0.0.0:9000
pm = dynamic
pm.max_children = 10
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 5
pm.max_requests = 500
; Slow request logging
request_slowlog_timeout = 5s
slowlog = /proc/self/fd/2How to calculate pm.max_children
This is the most important PHP-FPM setting and the one most people guess at. The formula:
max_children = (Available RAM - OS/DB overhead) / Average PHP process size
To find your average PHP process size:
docker exec -it your-php-container sh -c \
"ps --no-headers -o rss -p \$(pgrep php-fpm) | awk '{ sum += \$1; n++ } END { print sum/n/1024 \" MB\" }'"On a typical WordPress site, each PHP-FPM worker uses 30-60MB. On a WooCommerce site with a lot of plugins, it can be 80-120MB.
On a 2GB VPS:
- Available RAM: 2048MB
- OS overhead: ~200MB
- MariaDB: ~400MB
- Redis: 128MB (set by maxmemory)
- Remaining for PHP: ~1300MB
- Average process size: 60MB
- max_children: 1300 / 60 = ~21
Set it to 20 to leave a buffer. Setting it too high causes the server to swap to disk (catastrophically slow). Setting it too low causes 503 errors under traffic spikes.
I wrote a full guide on this: the PHP-FPM tuning post goes deeper into pm.start_servers, pm.min_spare_servers, and monitoring.
MariaDB configuration
The mounted custom.cnf:
[mysqld]
# InnoDB buffer pool - set to 50-70% of available DB memory
innodb_buffer_pool_size = 256M
# Log slow queries
slow_query_log = 1
long_query_time = 0.5
slow_query_log_file = /var/log/mysql/slow-query.log
# Connection handling
max_connections = 50
wait_timeout = 60
interactive_timeout = 60
# Temp tables
tmp_table_size = 64M
max_heap_table_size = 64M
# Query cache (MariaDB still supports this, MySQL 8 removed it)
query_cache_type = 1
query_cache_size = 32M
query_cache_limit = 2M
# Character set
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ciKey settings explained
innodb_buffer_pool_size: this is the single most important MariaDB performance setting. It controls how much data MariaDB keeps in RAM. Larger values mean more database reads come from memory instead of disk. Set it to 50-70% of the memory you've allocated for MariaDB. On a 2GB VPS where MariaDB gets about 400MB, set this to 256MB.
slow_query_log: enabled by default in our stack. Check the log at ./mariadb_logs/slow-query.log on the host. Essential for ongoing performance monitoring. See the slow MySQL queries guide for how to read and act on it.
max_connections = 50: WordPress uses one connection per request. With pm.max_children = 20 in PHP-FPM, you'll rarely exceed 20 simultaneous connections. Setting max_connections to 50 leaves headroom for WP-CLI, cron, and admin sessions. Setting it to the MySQL default of 151 wastes memory (each connection reserves a buffer).
Redis configuration
Redis runs with three flags in the docker-compose.yml:
maxmemory 128mb: hard memory limit. Prevents Redis from consuming all available RAM.maxmemory-policy allkeys-lru: when memory is full, evict the least recently used key. This is correct for a cache. Never usenoevictionfor a WordPress object cache because it causes errors when Redis is full.save "": disables RDB snapshots. We're using Redis as a volatile cache, not a database. If the container restarts, WordPress rebuilds the cache from MariaDB. Disabling saves reduces disk I/O and avoids background fork operations.
Connecting WordPress to Redis
Install the Redis Object Cache plugin by Till Kruss. Add this to your wp-config.php:
define('WP_REDIS_HOST', 'redis');
define('WP_REDIS_PORT', 6379);
define('WP_REDIS_DATABASE', 0);The host is redis because that's the service name in docker-compose.yml. Docker's internal DNS resolves it to the Redis container's IP.
After activating the plugin, go to Settings > Redis and click "Enable Object Cache." Verify the status shows "Connected."
The .env file
DOMAIN=yourdomain.com
DB_ROOT_PASSWORD=strong-random-password-here
DB_NAME=wordpress
DB_USER=wordpress
DB_PASSWORD=another-strong-random-passwordNever commit this file to version control. Add .env to .gitignore.
Deployment
First-time setup
On a fresh VPS (Ubuntu 22.04+ or Debian 12+):
# Install Docker
curl -fsSL https://get.docker.com | sh
# Clone your stack
git clone your-repo /opt/wordpress-stack
cd /opt/wordpress-stack
# Create .env file
cp .env.example .env
nano .env # Set your passwords and domain
# Point your DNS A record to this server's IP
# Start the stack
docker compose up -dCaddy automatically provisions an SSL certificate when the first request hits the domain. Give DNS 5-10 minutes to propagate.
Updating WordPress
WordPress core, plugin, and theme updates happen inside the container:
docker exec -it your-php-container wp core update
docker exec -it your-php-container wp plugin update --allOr update through the WordPress admin panel. The WordPress files live on the wordpress volume, so updates persist across container restarts.
Updating PHP version
Change the base image in Dockerfile.php:
FROM wordpress:php8.4-fpm-alpine # was php8.3Then rebuild:
docker compose build php
docker compose up -d phpYour WordPress files, database, and Redis cache are untouched. Only the PHP runtime changes.
Backups
Database backup to the host:
docker exec mariadb mysqldump -u root -p${DB_ROOT_PASSWORD} ${DB_NAME} > backup.sqlFull WordPress files backup:
docker cp php:/var/www/html ./wordpress-backup/Automate both with a cron job that runs daily and copies to off-server storage (S3, Backblaze B2, or rsync to a second server).
Monitoring
Container health
docker compose ps # Status of all containers
docker compose logs php # PHP-FPM logs
docker compose logs caddy # Access logs and errorsPHP-FPM status
Add this to your www.conf:
pm.status_path = /fpm-statusAnd to your Caddyfile (restrict to localhost):
@fpm_status remote_ip 127.0.0.1
handle @fpm_status {
php_fastcgi php:9000
}
Then check:
curl http://localhost/fpm-statusThis shows active processes, idle processes, and the request queue. If you consistently see 0 idle processes, your pm.max_children is too low.
Redis monitoring
docker exec redis redis-cli INFO stats | grep -E "hit|miss|used_memory_human"Check the hit ratio. Divide keyspace_hits by (keyspace_hits + keyspace_misses). Below 80% means Redis isn't caching effectively. Likely causes: maxmemory too low, or the WordPress site has more unique queries than Redis can hold.
MariaDB slow queries
docker exec mariadb tail -50 /var/log/mysql/slow-query.logWhat this stack doesn't include
This is a single-server stack. It's appropriate for WordPress sites handling up to ~100,000 monthly visitors on a properly sized VPS (4GB+ RAM). Beyond that, you need:
- Load balancing: multiple PHP-FPM containers behind a load balancer
- External database: managed MariaDB/MySQL (RDS, PlanetScale, or a separate database server)
- External Redis: managed Redis (ElastiCache, Upstash, or a separate Redis server)
- Object storage: media uploads on S3/Cloudflare R2 instead of local disk
Those are beyond the scope of this guide. The single-server Docker stack is the right starting point for most WordPress projects, and it's the one we deploy most often.
Related reading
- WordPress Slow After 2 Years? Here's What's Actually Wrong. The database problems to solve before any infrastructure change makes a difference.
- How to Find and Fix Slow MySQL Queries in WordPress. The slow query log and EXPLAIN techniques that work the same way inside Docker.
- How to Clean Your wp_options Table (The Right Way). Database cleanup that reduces the load on MariaDB regardless of your hosting setup.
- WP Rocket + Cloudflare: The Correct Configuration. Caching and CDN configuration that sits in front of this Docker stack.

Written by
Barry van Biljon
Full-stack developer specializing in high-performance web applications with React, Next.js, and WordPress.
Ready to Get Started?
Have questions about implementing these strategies? Our team is here to help you build high-performance web applications that drive results.
