Bitdoze Logo

How to Install WordPress with Docker Compose: Full Stack Guide

Install WordPress with Docker Compose. Complete stack with MySQL 8.4, phpMyAdmin, Cloudflare SSL, automatic backups, and Redis caching. Production-ready setup guide.

DragosDragos27 min read
How to Install WordPress with Docker Compose: Full Stack Guide

This guide walks you through installing WordPress with Docker Compose, covering a complete production-ready stack: MySQL 8.4 LTS, phpMyAdmin, automated database backups, optional Redis object caching, and Cloudflare Tunnels for SSL. Everything runs in five containers managed by a single compose.yaml file on any Linux VPS or Mini PC home server.

You’ll need a Linux VPS. I recommend Hetzner for the best price-to-performance ratio in Europe, or Hostinger if you prefer NVMe storage at a budget price. If you are running WooCommerce, you can also deploy the Woo Admin product dashboard alongside WordPress in Docker for faster product management.

Here’s what you get by following this guide:

  • WordPress container (pinned PHP version, persistent volume)
  • MySQL 8.4 LTS database (supported until 2032)
  • phpMyAdmin for database management
  • Automated database backups with rotation
  • Redis object cache for faster database queries
  • SSL via Cloudflare Tunnels (free plan)

I’ll use latest for some image tags below, but you can pin exact versions if you prefer predictable deploys. The one exception is MySQL. More on that in Step 3.

WordPress Docker Compose Stack: Step-by-Step Setup

1. Prerequisites for your WordPress Docker stack

  • A Linux VPS with 2GB+ RAM (4GB recommended for WordPress + Redis + phpMyAdmin)
  • Docker Engine 24+ and Docker Compose V2 (the docker compose plugin, not standalone docker-compose)
  • A domain name pointed at your server (or use direct IP access)
  • A Cloudflare account (free plan) for Tunnels and SSL
  • Port 5010 and 5011 available (or pick your own)

Docker Compose V2 syntax

This guide uses Docker Compose V2 syntax. The standalone docker-compose (V1) is deprecated and no longer receives updates. All examples use compose.yaml naming. If you’re still on V1, upgrade with apt install docker-compose-plugin or check your distro’s docs.

Dockge is optional but recommended. It gives you a web UI to manage your compose stacks without SSH-ing into the server. Check Dockge Install - Docker Compose Manager for Self-Hosting for the full setup. If you prefer a home server over a VPS, have a look at the best Mini PCs for home server. An ASUS DC510 works well for this kind of stack.

Familiarize yourself with these essential Docker commands before moving on. They’ll help with troubleshooting.

2. Create the project directory and config files

Docker bind-mounts behave badly when the source file doesn’t exist. Docker creates a directory instead of a file, and your PHP config won’t load. Create the config files first:

# Navigate to where the stack will live
cd /opt/stacks/wordpress

# Create the config directory and empty files
mkdir -p config
touch ./config/wp_php.ini
touch ./config/pma_php.ini
touch ./config/pma_config.php

# Set ownership so containers can write to volumes
# WordPress runs as UID 1000 inside the container
chown -R 1000:1000 ./config

File permissions matter

If you skip creating these files first, Docker will create them as directories, and your PHP config won’t load. After the first docker compose up, also check ownership of the data directories: chown -R 1000:1000 ./wp-app ./db_data ./backups to avoid permission denied errors inside WordPress.

Verify the files were created correctly:

ls -la ./config/

You should see regular files (-rw-r--r--), not directories (drwxr-xr-x).

3. Docker Compose file: WordPress, MySQL 8.4 LTS and phpMyAdmin

Here’s the full compose.yaml with all five services. I’ll explain the key decisions after the code.

Do NOT use mysql:latest

mysql:latest now tracks MySQL 9.x Innovation releases, short-lived versions with only ~3 months of support per minor release. For WordPress in production, use mysql:8.4 (LTS, supported until April 2032). The mysql:lts tag also works as a moving LTS pointer.

MySQL Innovation vs LTS

Since July 2024, MySQL uses a dual release track. Innovation releases (9.x) ship new features quarterly but have a short support window. Fine for testing, bad for production. LTS releases (8.4, future 9.7) get 5 years of premier support plus 3 years of extended support. Always use LTS for anything that stores data you care about.

Key decisions in this compose file:

  • wordpress:php8.3 instead of wordpress:latest: pins PHP to 8.3, which is the WordPress-recommended version. The latest tag also ships PHP 8.3 as of mid-2025, but pinning the tag avoids surprises when the default changes.
  • mysql:8.4: the current LTS release, supported until 2032.
  • redis:7-alpine: pinned version, Alpine-based for a smaller image (~30MB vs ~130MB).
  • tiredofit/db-backup:4.1: pinned major version. This image is migrating to nfrastack/container-db-backup. The old one still works fine but watch for the new release.
  • COMPRESSION: ZSTD: the new default in db-backup, faster compression and decompression than GZ.
  • FS_METHOD: direct: tells WordPress to write files directly instead of using FTP, which doesn’t work in Docker.
  • Health checks: MySQL has a mysqladmin ping check, WordPress has a curl check. The depends_on: condition: service_healthy means WordPress waits for MySQL to be actually ready, not just started.
  • Resource limits: keeps each container from eating all your RAM. Adjust based on your VPS size.

For production, consider Docker Compose secrets instead of .env files. They’re more secure and don’t leave credentials in shell history or process listings.

4. Configure the .env file and security keys

Create a .env file in the same directory as your compose.yaml:

DB_NAME='wordpress'
DB_USER='wp'
DB_PASSWORD='use-a-strong-random-password-here'
DB_ROOT_PASSWORD=another-strong-random-password

Now add the WordPress security salts. These are 8 cryptographic keys that WordPress uses to encrypt cookies and authentication tokens. The official Docker image generates unique random SHA1 hashes from whatever values you provide. It’s a free security upgrade.

Free security upgrade

Adding WordPress security salts costs nothing and makes session hijacking significantly harder. The Docker image reads these environment variables and writes the corresponding define() constants into wp-config.php on first boot.

Add these to your .env file:

WORDPRESS_AUTH_KEY='put-unique-phrase-here'
WORDPRESS_SECURE_AUTH_KEY='put-unique-phrase-here'
WORDPRESS_LOGGED_IN_KEY='put-unique-phrase-here'
WORDPRESS_NONCE_KEY='put-unique-phrase-here'
WORDPRESS_AUTH_SALT='put-unique-phrase-here'
WORDPRESS_SECURE_AUTH_SALT='put-unique-phrase-here'
WORDPRESS_LOGGED_IN_SALT='put-unique-phrase-here'
WORDPRESS_NONCE_SALT='put-unique-phrase-here'

Generate real random values from the official WordPress salt generator:

Generate WP Salts →

Replace each put-unique-phrase-here with the generated values. Don’t reuse these across installations.

If you’re using Dockge, you can add these as environment variables in the stack config instead of a .env file. For production setups, look at Docker Compose secrets or the _FILE environment variable variants the WordPress image supports (e.g., WORDPRESS_DB_PASSWORD_FILE=/run/secrets/wp-db-password).

5. Start WordPress in Docker

If you’re using Dockge, save the compose file and click Start. Otherwise:

docker compose up -d

Watch the logs to catch any startup errors:

docker compose logs -f

Wait about 30 seconds, then check that all containers are healthy:

docker compose ps

You should see all services with Up status. If you included health checks, the wp-db service should show (healthy) after a few seconds, and the wp service after about 30 seconds.

Startup order matters

The depends_on: condition: service_healthy on the wp service means WordPress won’t start until MySQL passes its health check. This prevents the common “Error establishing a database connection” race condition you get with plain depends_on.

If something fails:

  • Port 5010 or 5011 already in use: lsof -i :5010 to find what’s occupying it, then change the port mapping
  • MySQL health check keeps failing: docker compose logs wp-db (look for authentication or config errors)
  • WordPress can’t connect to DB: verify the .env values match between the wp and wp-db services

6. Configure Cloudflare Tunnels for SSL

Cloudflare Tunnels give you SSL and DDoS protection without opening ports on your firewall or managing certificates.

Updated dashboard path

The Cloudflare dashboard path has changed. Navigate to Zero Trust → Networks → Tunnels, not the old “Access → Tunnels” path that older guides reference.

In the Cloudflare Zero Trust dashboard:

  1. Go to Zero Trust → Networks → Tunnels
  2. Select your tunnel (or create one with cloudflared)
  3. Add a hostname mapping your domain to http://localhost:5010
  4. Save — Cloudflare handles SSL automatically
Cloudflare Tunnel setup

You can add a second hostname for phpMyAdmin on a subdomain (e.g., pma.yourdomain.com) pointing to http://localhost:5011. I’d recommend this over exposing port 5011 directly.

Verify the tunnel works:

curl -I https://yourdomain.com

You should get a 200 or 301 response with cf-ray and server: cloudflare headers.

502 Bad Gateway? WordPress container isn’t running or the port mapping is wrong. Run docker compose ps and check that the wp service is Up.

You can also use Traefik v3 as a reverse proxy if you prefer managing SSL yourself, or CloudPanel with Dockge for a different reverse proxy approach.

7. Complete the WordPress installation

Open your domain in the browser (or http://your-server-ip:5010 if you haven’t set up Cloudflare yet). You’ll see the WordPress installation wizard:

WordPress Docker Setup

Choose your language, create an admin account, and you’re in. After that, configure permalinks under Settings → Permalinks (I use “Post name” for most sites) and start adding themes and plugins.

8. Customize PHP settings for WordPress in Docker

Edit ./config/wp_php.ini to tune PHP for WordPress:

file_uploads = On
memory_limit = 256M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 300
max_input_time = 1000

Bump memory_limit to 512M and upload_max_filesize to 128M if you’re running WooCommerce or uploading large media files.

After editing, restart the WordPress container:

docker compose restart wp

PHP version in the WordPress image

The wordpress:latest image ships PHP 8.2 as of mid-2025. If you followed this guide, you’re using wordpress:php8.3 which is the WordPress-recommended minimum. The wordpress:php8.4 tag is also available and fully supported by WordPress 6.7+. Check your version with: docker exec wp php -v

Database management and backups

9. Access phpMyAdmin for database management

Access phpMyAdmin at http://your-server-ip:5011 (or via a Cloudflare tunnel subdomain). Log in with the database credentials from your .env file — the DB_USER and DB_PASSWORD values.

The UPLOAD_LIMIT: 100M in the compose file lets you import larger database dumps through the phpMyAdmin UI.

Don't expose phpMyAdmin publicly

phpMyAdmin gives full access to your database. In production, firewall off port 5011 and only access it through a Cloudflare Tunnel with an Access policy, or use SSH tunneling: ssh -L 5011:localhost:5011 your-server-ip.

10. Verify automatic database backups

The wp-db-backup container runs on a schedule defined by DB_BACKUP_INTERVAL: 720 (every 12 hours) and cleans up backups older than DB_CLEANUP_TIME: 72000 minutes (~50 days).

Check the backup directory:

ls -ltr ./backups/

You should see files like:

-rw------- 1 10000 10000  495 Jul 17 09:26 mysql_wordpress_wp-db_20250717-092619.sql.zst
-rw------- 1 10000 10000   87 Jul 17 09:26 mysql_wordpress_wp-db_20250717-092619.sql.zst.sha1
lrwxrwxrwx 1 10000 10000   44 Jul 17 09:26 latest-mysql_wordpress_wp-db -> mysql_wordpress_wp-db_20250717-092619.sql.zst

Backup image migration

The tiredofit/db-backup image is migrating to nfrastack/container-db-backup. The current image (pinned at 4.1) still works fine. Watch for the new release if you’re setting this up after mid-2026. The compression extension changed from .sql.gz to .sql.zst (ZSTD is faster than GZ).

For full site backups (files + database), pair this with a WordPress backup plugin — see Best Free WordPress Backup Plugins for options that handle themes, plugins, and uploads too.

11. How to restore a database backup

Test your restores

A backup you can’t restore is not a backup. Run through this procedure at least once after initial setup to make sure it works.

To restore from a compressed backup:

# For ZSTD-compressed backups (new default)
zstd -d ./backups/latest-mysql_wordpress_wp-db -c | docker exec -i wp-db mysql -u "${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}"

# For GZ-compressed backups (if you haven't updated compression)
zcat ./backups/latest-mysql_wordpress_wp-db | docker exec -i wp-db mysql -u "${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}"

To verify backup integrity before restoring, check the SHA1 sidecar file:

cd ./backups
sha1sum -c mysql_wordpress_wp-db_20250717-092619.sql.zst.sha1

After restoring, open WordPress admin and confirm your posts and pages are present.

Common errors:

  • “Access denied” — wrong credentials or missing quotes around the password
  • “Unknown database” — the DB_NAME in the restore command doesn’t match the backup
  • “ERROR 2006 (HY000)” — MySQL server has gone away, the dump is too large; increase max_allowed_packet in MySQL config

Optional performance improvements

12. Add Redis Object Cache to WordPress in Docker

Redis requires a PHP extension

The official WordPress Docker image does NOT include the Redis PHP extension. Adding the redis-wp service to your compose file is not enough — you must also install the phpredis extension or use the Predis pure-PHP library. Without this, the Redis Object Cache plugin will show “Not connected.”

There are three ways to add Redis support. I recommend the custom Dockerfile approach — it’s the cleanest.

After activating the Redis Object Cache plugin (by Till Krüss), go to Settings → Redis in WordPress admin and click Enable Object Cache. The status should show “Connected.”

Not connecting? Check that:

  1. The Redis container is running: docker compose ps redis-wp
  2. The host/port in WP_REDIS_HOST / WP_REDIS_PORT match the service name and port
  3. The PHP Redis extension is actually installed: docker exec wp php -m | grep redis

For maximum performance, combine Redis caching with Varnish and Cloudflare — see How to Speed Up WordPress with Cloudflare, Varnish and Redis.

Production hardening

13. Docker health checks for production

Health checks are already configured in the compose file from Step 3. Here’s what they do:

  • MySQL (wp-db): Runs mysqladmin ping every 10 seconds. After 5 failed checks, the container is marked unhealthy. This prevents WordPress from connecting before MySQL is ready.
  • WordPress (wp): Curls the install page every 30 seconds. Confirms the web server and PHP are responding.

Monitor health status:

docker compose ps

All services should show (healthy) in the STATUS column. If a service is (unhealthy), check its logs: docker compose logs <service-name>.

The restart: unless-stopped policy means containers auto-restart on failure or server reboot, but stay stopped if you manually stop them.

14. WP-CLI container for WordPress maintenance

The official wordpress:cli image gives you command-line access to WordPress without installing anything extra. Run commands against your existing WordPress container:

docker run -it --rm \
  --volumes-from wp \
  --network container:wp \
  wordpress:cli \
  wp plugin list
Common WP-CLI commands

Plugin management:

# List installed plugins
docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp plugin list

# Update all plugins
docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp plugin update --all

# Deactivate a plugin
docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp plugin deactivate plugin-name

User management:

# List users
docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp user list

# Reset a user password
docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp user update admin --user_pass=newpassword

Database operations:

# Export database
docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp db export /var/www/html/backup.sql

# Search and replace URLs (useful after domain changes)
docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp search-replace 'http://old-domain.com' 'https://new-domain.com' --skip-columns=guid

Core updates:

# Check current version
docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp core version

# Update WordPress core
docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp core update

The FS_METHOD: direct define in WORDPRESS_CONFIG_EXTRA is required for WP-CLI (and WordPress itself) to write files in Docker without FTP.

“Error: This does not seem to be a WordPress install”? You’re missing --volumes-from wp or the container name is wrong. Check with docker compose ps.

15. Security hardening checklist

  • MySQL 8.4 LTS pinned (not mysql:latest)
  • WordPress security salts set in .env
  • FS_METHOD: direct in WORDPRESS_CONFIG_EXTRA
  • Port 5011 (phpMyAdmin) firewalled off — access only via Cloudflare Tunnel or SSH
  • Cloudflare Access policy in front of phpMyAdmin subdomain
  • Database passwords are strong random strings, not dictionary words
  • WordPress table prefix changed from wp_ if you’re paranoid (set in WORDPRESS_TABLE_PREFIX)
  • XML-RPC disabled if you’re not using Jetpack (add to .htaccess or use a plugin)
  • Resource limits set in compose file to prevent runaway containers
  • Regular backup restores tested (not just backup creation)

Already covered

Most of these are already handled by following Steps 3-6 of this guide. This checklist is here for reference and for when you’re auditing your setup later.

Monitor your server to detect anomalies early — see How To Monitor Server and Docker Resources for setting up resource monitoring with tools like Beszel or Netdata.

16. MariaDB as a MySQL alternative

MariaDB is fully compatible with WordPress, has a lighter memory footprint, and is recommended alongside MySQL in the WordPress Hosting Handbook. Many self-hosters prefer it.

To swap, change one line in your compose file:

# Replace this:
  wp-db:
    image: mysql:8.4

# With this:
  wp-db:
    image: mariadb:11.4

Same env vars, same everything

MariaDB uses the same environment variables as MySQL (MYSQL_ROOT_PASSWORD, MYSQL_DATABASE, etc.). No other changes needed — just swap the image tag. MariaDB 11.4 LTS is supported until May 2029.

17. Updating WordPress in Docker

Two strategies depending on how much control you want:

Strategy 1: Self-managing (default)

WordPress auto-updates itself inside the volume. This is the default behavior — WordPress checks for updates and applies them without you touching Docker. Simple, but your infrastructure isn’t immutable.

Strategy 2: Pinned version (recommended for production)

Pin the WordPress image version in your compose file, disable auto-updates, and control when you update:

wp:
  image: wordpress:php8.3:6.8

Add to WORDPRESS_CONFIG_EXTRA:

define('WP_AUTO_UPDATE_CORE', false);

When you’re ready to update, change the version tag and redeploy:

docker compose pull
docker compose up -d

Your wp-app volume persists all WordPress files, themes, plugins, and uploads. The container is just the runtime — your data lives on the host.

“Another update is in progress”? This is a stuck transient. Clear it with WP-CLI:

docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp option delete core_updater.lock

18. What’s Next

Your WordPress Docker stack is running. A few things to do from here:

  • Close firewall ports — if you’re using Cloudflare Tunnels, block ports 5010 and 5011 at the firewall level so only the tunnel can reach them. Access is only through your domain.
  • Set up monitoringmonitor your server and Docker resources to catch CPU spikes, disk fill-ups, and container restarts before they become problems.
  • Explore self-hosted panels — if you want a broader management interface, check the best self-hosted server panels for options beyond Dockge.
  • Master Docker commands — bookmark these essential Docker commands for troubleshooting containers, cleaning up disk space, and managing images.
  • Install themes and plugins — WordPress is ready for your content. Start with a lightweight theme and add only the plugins you need.

Conclusion

You now have a production-ready WordPress stack running in Docker with MySQL 8.4 LTS (supported until 2032), phpMyAdmin for database management, automated backups with ZSTD compression, optional Redis object caching, and SSL through Cloudflare Tunnels — all from a single compose.yaml file.

The most important next step is testing your backups. A backup you’ve never restored is a gamble, not a strategy. Run through the restore procedure in Step 11 at least once, then set a calendar reminder to test it quarterly.

If something breaks, docker compose logs is your best friend. Most issues come down to port conflicts, permission errors, or MySQL not being ready when WordPress tries to connect — all of which the health checks in this setup are designed to catch.