Running WordPress in containers keeps each site isolated with its own PHP version and database, makes moving between servers easy, and lets several sites share one VPS cleanly. Here’s a production-minded Compose setup.
Compose file
mkdir -p ~/sites/example.com && cd ~/sites/example.com
compose.yaml:
services:
db:
image: mariadb:11
restart: unless-stopped
environment:
MARIADB_DATABASE: wordpress
MARIADB_USER: wordpress
MARIADB_PASSWORD: ${DB_PASSWORD}
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- db:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--maxmemory", "128mb", "--maxmemory-policy", "allkeys-lru"]
wordpress:
image: wordpress:php8.3-apache
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
WORDPRESS_CONFIG_EXTRA: |
define('WP_REDIS_HOST', 'redis');
define('DISABLE_WP_CRON', true);
define('FORCE_SSL_ADMIN', true);
if (($$_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https') { $$_SERVER['HTTPS'] = 'on'; }
volumes:
- wp:/var/www/html
- ./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro
ports:
- "127.0.0.1:8081:80"
volumes:
db:
wp:
.env:
DB_PASSWORD=change-me-long-random
DB_ROOT_PASSWORD=another-long-random
uploads.ini:
upload_max_filesize = 64M
post_max_size = 64M
memory_limit = 256M
max_execution_time = 120
chmod 600 .env
docker compose up -d
Notes:
$$escapes$for Compose insideWORDPRESS_CONFIG_EXTRA.- The official image exists in Apache (
-apache) and PHP-FPM (-fpm) variants. The Apache variant is self-contained and simplest behind a reverse proxy; the FPM variant needs a separate Nginx container. - WordPress is published on localhost port 8081 only — the reverse proxy handles the public side.
HTTPS with a reverse proxy
On the host, an Nginx server block:
server {
listen 80;
server_name example.com www.example.com;
client_max_body_size 64M;
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Then sudo certbot --nginx -d example.com -d www.example.com. The X-Forwarded-Proto header plus the snippet in WORDPRESS_CONFIG_EXTRA prevents redirect loops — see too many redirects. Or use Caddy / Traefik on a shared Docker network.
Finish the installer at https://example.com.
WP-CLI
Add a cli service that shares the WordPress volume and database settings, and only starts on demand:
cli:
image: wordpress:cli
profiles: [tools]
user: "33:33"
depends_on: [db]
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
volumes:
- wp:/var/www/html
docker compose run --rm cli wp plugin list
docker compose run --rm cli wp core update
User 33:33 is www-data in the official images, so files keep the right owner. See the WP-CLI guide.
Real cron
With DISABLE_WP_CRON, trigger cron from the host:
*/5 * * * * docker compose -f /home/deploy/sites/example.com/compose.yaml exec -T wordpress php /var/www/html/wp-cron.php > /dev/null 2>&1
Redis object cache
Install the Redis Object Cache plugin and click Enable — WP_REDIS_HOST already points to the redis service. See WordPress Redis object cache.
Backups
docker compose exec -T db mariadb-dump -u root -p"$DB_ROOT_PASSWORD" --single-transaction wordpress | gzip > db-$(date +%F).sql.gz
docker run --rm -v example-com_wp:/data:ro -v "$PWD":/backup alpine tar czf /backup/wp-files-$(date +%F).tar.gz -C /data .
(The volume name is prefixed with the project name — check docker volume ls.) Copy backups off the server. See backing up Docker volumes.
Updates
WordPress core, plugins and themes update inside the wp volume as usual (dashboard or WP-CLI). The image provides PHP and Apache — update it to get PHP security fixes:
docker compose pull && docker compose up -d
The image only copies WordPress files into an empty volume, so pulling a newer image won’t overwrite your installation.
Several sites on one server
Copy the directory per site with its own .env, ports and volumes, and add a server block (or Caddy/Traefik route) per domain. Each site gets isolated PHP and database containers. Watch total RAM: each MariaDB instance needs memory, so on smaller servers a single shared database container with one database per site is more economical.
Related: installing WordPress without Docker and the WordPress VPS page.