Docker & DevOps

Docker Compose for WordPress: Nginx, PHP-FPM and MariaDB

Build a WordPress stack with Docker Compose using separate Nginx, PHP-FPM and MariaDB containers, a persistent wp-content volume and a free Let's Encrypt certificate.

IntermediateLinux server6 min read

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.com with 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:

Bash
docker --version
docker compose version

Write the WordPress Docker Compose file

Create the project directory and the folders the containers will mount:

Bash
mkdir -p ~/wordpress-docker/{nginx,php,certbot/conf,certbot/www}
cd ~/wordpress-docker

Create a .env file to hold the database passwords. Keeping them out of the Compose file makes it safe to commit the rest to Git:

INI
DB_PASSWORD=change-this-app-password
DB_ROOT_PASSWORD=change-this-root-password

Now create docker-compose.yml:

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_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:

INI
upload_max_filesize = 64M
post_max_size = 64M
memory_limit = 256M
max_execution_time = 300

Create nginx/wordpress.conf. This first version serves the site over HTTP and answers the ACME challenge so Certbot can issue a certificate:

Nginx
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:

Bash
docker compose up -d
docker compose ps

Wait 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:

Bash
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-email

Now replace nginx/wordpress.conf with a version that redirects HTTP to HTTPS and serves the site over TLS:

Nginx
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:

Bash
docker compose exec nginx nginx -t
docker compose exec nginx nginx -s reload

Certificates expire after 90 days. Add a daily cron job on the host to renew and reload:

Bash
0 3 * * * cd /root/wordpress-docker && docker compose run --rm certbot renew --webroot -w /var/www/certbot && docker compose exec nginx nginx -s reload

Tip: 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:

  1. curl -I https://example.com returns HTTP/2 200 and a strict-transport-security header is not required, but the certificate must be valid.
  2. curl -I http://example.com returns 301 to the HTTPS URL.
  3. Log in at https://example.com/wp-admin and confirm the dashboard loads.
  4. Upload a file larger than 2 MB in the media library — it should succeed if uploads.ini is mounted correctly.
  5. docker compose ps shows all services running and db as healthy.
  6. Restart the stack with docker compose down && docker compose up -d and 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 .env do not match the ones MariaDB was initialised with. Check docker compose logs db, and if you changed passwords after the first boot, remove the volume with docker compose down -v and start again (this deletes all data).
  • 502 Bad Gateway from Nginx — PHP-FPM is not running or the fastcgi_pass host is wrong. Confirm the service is named wordpress in docker-compose.yml and check docker compose logs wordpress.
  • "The uploaded file exceeds the upload_max_filesize directive" — php/uploads.ini is missing or mounted at the wrong path. Verify the file exists and restart the wordpress container.
  • Redirect loop or mixed-content warnings — the siteurl and home values in the database still point at http://. Fix them with docker 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.10 yet. Check with dig +short example.com and 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

Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.