Skip to main content
· Containers · 3 min read

Multi-Site WordPress on Docker: Reverse Proxy, SSL, and Containerized PHP

The challenge with multiple WordPress sites: each might need a different PHP version. Some sites run old PHP 5.6 for legacy plugins. Others use PHP 7.4 or 8.x. Different library versions and separate database credentials add complexity.

Docker handles this naturally. Each site runs in its own container with its own PHP version. They share only the host’s reverse proxy.

The Architecture

%%{ init: { 'look': 'handDrawn' } }%%
graph LR
    A[Internet] -->|443 / 80| B[jwilder/nginx-proxy<br/>Reverse proxy + routing]
    B -->|VIRTUAL_HOST| C[WordPress PHP 7.2<br/>app1.example.com]
    B -->|VIRTUAL_HOST| D[WordPress PHP 5.6<br/>app2.example.com]
    B -->|ACME challenges| E[letsencrypt-nginx-proxy-companion<br/>SSL certificates]
    C -->|DB_HOST| F[MariaDB<br/>host.docker.internal:3306]
    D -->|DB_HOST| F
    C -->|file storage| G[./app_1_volume]
    D -->|file storage| H[./app_2_volume]

The reverse proxy inspects the VIRTUAL_HOST environment variable on each container and routes traffic accordingly. The Let’s Encrypt companion watches for new containers and automatically provisions SSL certificates.

docker-compose.yml

version: '3'
services:
  reverse-proxy:
    image: jwilder/nginx-proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./certs:/etc/nginx/certs:rw
      - /etc/nginx/vhost.d
      - /usr/share/nginx/html
      - /var/run/docker.sock:/tmp/docker.sock:ro
    restart: unless-stopped

  letsencrypt-nginx-proxy-companion:
    image: jrcs/letsencrypt-nginx-proxy-companion
    volumes:
      - ./certs:/etc/nginx/certs:rw
      - /var/run/docker.sock:/var/run/docker.sock:ro
    volumes_from:
      - reverse-proxy
    environment:
      - NGINX_PROXY_CONTAINER=reverse-proxy
    restart: unless-stopped

  wordpress-app1:
    image: wordpress:php7.2-apache
    environment:
      - WORDPRESS_DB_HOST=host.docker.internal:3306
      - WORDPRESS_DB_USER=db1user
      - WORDPRESS_DB_PASSWORD=${DB1_PASSWORD}
      - WORDPRESS_DB_NAME=app1_db
      - VIRTUAL_HOST=www.app1.example.com
      - LETSENCRYPT_HOST=www.app1.example.com
      - [email protected]
    volumes:
      - ./app1/html:/var/www/html
      - ./app1/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini
    restart: unless-stopped

  wordpress-app2:
    image: wordpress:php5.6-apache
    environment:
      - WORDPRESS_DB_HOST=host.docker.internal:3306
      - WORDPRESS_DB_USER=db1user
      - WORDPRESS_DB_PASSWORD=${DB1_PASSWORD}
      - WORDPRESS_DB_NAME=app2_db
      - VIRTUAL_HOST=www.app2.example.com
      - LETSENCRYPT_HOST=www.app2.example.com
      - [email protected]
    volumes:
      - ./app2/html:/var/www/html
      - ./app2/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini
    restart: unless-stopped

Key points:

  • host.docker.internal routes to the host’s MariaDB — no separate DB container needed
  • VIRTUAL_HOST + LETSENCRYPT_HOST on each container triggers automated SSL
  • Different wordpress:* images allow different PHP versions on the same host
  • Each app has its own volume mount for uploads and custom PHP settings

PHP Upload Configuration

# app1/uploads.ini
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 300
# app2/uploads.ini (legacy site, lower limits)
upload_max_filesize = 8M
post_max_size = 8M
max_execution_time = 60

Each container gets its own uploads.ini mounted at the PHP confd path — per-site PHP configuration without rebuilding images.

Database Setup on Host

# Install MariaDB on the host (not in Docker)
apt install -y mariadb-server

mysql -u root -p <<EOF
CREATE DATABASE app1_db;
CREATE USER 'db1user'@'%' IDENTIFIED BY '${DB1_PASSWORD}';
GRANT ALL PRIVILEGES ON app1_db.* TO 'db1user'@'%';
FLUSH PRIVILEGES;

CREATE DATABASE app2_db;
CREATE USER 'db2user'@'%' IDENTIFIED BY '${DB2_PASSWORD}';
GRANT ALL PRIVILEGES ON app2_db.* TO 'db2user'@'%';
FLUSH PRIVILEGES;
EOF

# Configure MariaDB to listen on all interfaces
sed -i 's/bind-address.*=.*127.0.0.1/bind-address = 0.0.0.0/' /etc/mysql/mariadb.conf.d/50-server.cnf
systemctl restart mariadb

MariaDB runs on the host, accessible to containers via host.docker.internal.

Deployment Checklist

%%{ init: { 'look': 'handDrawn' } }%%
graph TD
    A[New WordPress site deploy] --> B[Create database<br/>in MariaDB]
    B --> C[Add docker-compose<br/>service block]
    C --> D[Set VIRTUAL_HOST +<br/>LETSENCRYPT_HOST]
    D --> E[docker-compose up -d]
    E --> F[ACME companion<br/>provisions SSL]
    F --> G[Site live with<br/>HTTPS + HTTP/2]
# Deploy a new site
docker-compose up -d wordpress-newsite

# Check SSL status
docker logs letsencrypt-nginx-proxy-companion | grep new-site

# Renew certificates (automatic, handled by companion)
# Manual force-renew if needed
docker exec letsencrypt-nginx-proxy-companion sh -c "while true; do sleep 12h; /app/signal_occurences.sh; done"

The setup is self-organizing — add a new container with the right VIRTUAL_HOST and SSL is provisioned automatically.