WordPress Docker Compose splits the web server, the PHP runtime and the database into separate containers, so you can upgrade or rebuild any one of them without disturbing the others. This guide builds a three-service stack — Nginx, PHP-FPM and MariaDB — with a persistent volume for your site files and a Let's Encrypt certificate for HTTPS.
The result is a WordPress site where the code lives in a Docker volume, the database lives in a named volume, and Nginx is the only thing listening on ports 80 and 443.
Before you start
You need:
- A Linux VPS running Ubuntu 22.04/24.04, Debian 12, AlmaLinux 9 or Rocky 9, with root or sudo access.
- Docker Engine and the Compose plugin installed. If not, follow Install Docker and Docker Compose on a Linux VPS.
- A domain such as
example.comwith an A record pointing to your server IP (203.0.113.10). - Ports 80 and 443 open in your firewall, and nothing else already bound to them.
Warning: If Apache, Nginx or another web server is already running on the host, stop it first (sudo systemctl disable --now nginx apache2) or the containers will fail to bind port 80.
Check Docker is ready:
docker --version
docker compose versionWrite the WordPress Docker Compose file
Create the project directory and the folders the containers will mount:
mkdir -p ~/wordpress-docker/{nginx,php,certbot/conf,certbot/www}
cd ~/wordpress-dockerCreate a .env file to hold the database passwords. Keeping them out of the Compose file makes it safe to commit the rest to Git:
DB_PASSWORD=change-this-app-password
DB_ROOT_PASSWORD=change-this-root-passwordNow create docker-compose.yml:
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_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
wordpress:
image: wordpress:php8.3-fpm
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
volumes:
- wp_data:/var/www/html
- ./php/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro
nginx:
image: nginx:1.27-alpine
restart: unless-stopped
depends_on:
- wordpress
ports:
- "80:80"
- "443:443"
volumes:
- wp_data:/var/www/html:ro
- ./nginx/wordpress.conf:/etc/nginx/conf.d/default.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
certbot:
image: certbot/certbot
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
volumes:
db_data:
wp_data:Two things matter here. The wp_data volume is mounted into both the PHP container (read-write) and Nginx (read-only), so both see the same files. The depends_on with condition: service_healthy stops WordPress from booting before MariaDB accepts connections — the reason for the healthcheck.
Add the Nginx and PHP-FPM configuration
PHP-FPM ships with small upload limits. Create php/uploads.ini:
upload_max_filesize = 64M
post_max_size = 64M
memory_limit = 256M
max_execution_time = 300Create nginx/wordpress.conf. This first version serves the site over HTTP and answers the ACME challenge so Certbot can issue a certificate:
server {
listen 80;
server_name example.com www.example.com;
root /var/www/html;
index index.php;
client_max_body_size 64m;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
fastcgi_pass wordpress:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_read_timeout 300;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff2?)$ {
expires max;
log_not_found off;
}
location ~ /\.ht {
deny all;
}
}fastcgi_pass wordpress:9000 uses the Compose service name — Docker's internal DNS resolves it, so no IP addresses are needed.
Start the stack and install WordPress
Bring everything up:
docker compose up -d
docker compose psWait until db shows healthy and the other three show running. Then open http://example.com in a browser. You should see the WordPress language picker. Complete the installer with a site title, an admin username (avoid admin), a strong password and your email.
WordPress writes wp-config.php into the volume on first start, using the environment variables from the Compose file. You never edit it by hand.
Add HTTPS with Let's Encrypt
Request the certificate. Certbot writes the challenge file into certbot/www, which Nginx serves from /var/www/certbot:
docker compose run --rm certbot certonly \
--webroot -w /var/www/certbot \
-d example.com -d www.example.com \
--email admin@example.com --agree-tos --no-eff-emailNow replace nginx/wordpress.conf with a version that redirects HTTP to HTTPS and serves the site over TLS:
server {
listen 80;
server_name example.com www.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
root /var/www/html;
index index.php;
client_max_body_size 64m;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
fastcgi_pass wordpress:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTPS on;
fastcgi_read_timeout 300;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff2?)$ {
expires max;
log_not_found off;
}
location ~ /\.ht {
deny all;
}
}Reload Nginx without restarting the container:
docker compose exec nginx nginx -t
docker compose exec nginx nginx -s reloadCertificates expire after 90 days. Add a daily cron job on the host to renew and reload:
0 3 * * * cd /root/wordpress-docker && docker compose run --rm certbot renew --webroot -w /var/www/certbot && docker compose exec nginx nginx -s reloadTip: If you would rather not manage certificates by hand, Nginx Proxy Manager in Docker handles HTTPS for many self-hosted apps from a web UI.
Check it works
Run through these checks:
curl -I https://example.comreturnsHTTP/2 200and astrict-transport-securityheader is not required, but the certificate must be valid.curl -I http://example.comreturns301to the HTTPS URL.- Log in at
https://example.com/wp-adminand confirm the dashboard loads. - Upload a file larger than 2 MB in the media library — it should succeed if
uploads.iniis mounted correctly. docker compose psshows all servicesrunninganddbashealthy.- Restart the stack with
docker compose down && docker compose up -dand confirm your posts, theme and uploads are still there.
Troubleshooting
- "Error establishing a database connection" — WordPress started before MariaDB was ready, or the credentials in
.envdo not match the ones MariaDB was initialised with. Checkdocker compose logs db, and if you changed passwords after the first boot, remove the volume withdocker compose down -vand start again (this deletes all data). - 502 Bad Gateway from Nginx — PHP-FPM is not running or the
fastcgi_passhost is wrong. Confirm the service is namedwordpressindocker-compose.ymland checkdocker compose logs wordpress. - "The uploaded file exceeds the upload_max_filesize directive" —
php/uploads.iniis missing or mounted at the wrong path. Verify the file exists and restart thewordpresscontainer. - Redirect loop or mixed-content warnings — the
siteurlandhomevalues in the database still point athttp://. Fix them withdocker compose exec db mariadb -u wordpress -p wordpress -e "UPDATE wp_options SET option_value='https://example.com' WHERE option_name IN ('siteurl','home');". - Certbot fails with "Timeout during connect" — port 80 is blocked by a firewall, or DNS for the domain does not point at
203.0.113.10yet. Check withdig +short example.comand open port 80. - Permission denied writing to wp-content — the volume is owned by the wrong UID. Run
docker compose exec wordpress chown -R www-data:www-data /var/www/html/wp-content.
Next steps
- Compare web servers before you commit: Nginx vs Apache vs OpenLiteSpeed for WordPress on a VPS.
- Prefer a classic install without containers? Install WordPress on a LEMP stack.
- Keep secrets out of your Compose file: Manage secrets and environment variables in Docker Compose.
- Ship updates without downtime: Zero-downtime deploys with Docker Compose and a reverse proxy.
