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 inside WORDPRESS_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.

  • WordPress
  • Docker
  • Docker Compose