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.internalroutes to the host’s MariaDB — no separate DB container neededVIRTUAL_HOST+LETSENCRYPT_HOSTon 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.