---
title: "How to Install WordPress with Docker Compose: Full Stack Guide"
description: "Install WordPress with Docker Compose. Complete stack with MySQL 8.4, phpMyAdmin, Cloudflare SSL, automatic backups, and Redis caching. Production-ready setup guide."
date: 2026-07-24
categories: ["wordpress"]
tags: ["self-hosted","docker","docker-compose"]
---

import { Picture } from "astro:assets";
import imag1 from "../../assets/images/24/01/cloudflare-tunel-setup.png";
import imag2 from "../../assets/images/24/02/docker-wp-access.png";
import YouTubeEmbed from "../../components/widgets/YouTubeEmbed.astro";
import Button from "@components/widgets/Button.astro";
import Notice from "@components/widgets/Notice.astro";
import ListCheck from "@components/widgets/ListCheck.astro";
import Accordion from "@components/widgets/Accordion.astro";
import Tabs from "@components/widgets/Tabs.astro";
import Tab from "@components/widgets/Tab.astro";

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](https://www.bitdoze.com/best-mini-pc-home-server/).

You'll need a Linux VPS. I recommend [Hetzner](https://go.bitdoze.com/hetzner) for the best price-to-performance ratio in Europe, or [Hostinger](https://go.bitdoze.com/hostinger-vps) if you prefer NVMe storage at a budget price. If you are running WooCommerce, you can also deploy the [Woo Admin product dashboard](https://www.bitdoze.com/woocommerce-admin-dashboard/) alongside WordPress in Docker for faster product management.

Here's what you get by following this guide:

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

> 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

<YouTubeEmbed
  url="https://www.youtube.com/embed/m3FNd_7MSGQ"
  label="Install WordPress in a Docker Container with Docker Compose"
/>

### 1. Prerequisites for your WordPress Docker stack

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

<Notice type="info" title="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.
</Notice>

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](https://www.bitdoze.com/dockge-install/) for the full setup. If you prefer a home server over a VPS, have a look at the [best Mini PCs for home server](https://www.bitdoze.com/best-mini-pc-home-server/). An [ASUS DC510](https://go.bitdoze.com/asus-dc510) works well for this kind of stack.

Familiarize yourself with these [essential Docker commands](https://www.bitdoze.com/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:

```sh
# 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
```

<Notice type="warning" title="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.
</Notice>

Verify the files were created correctly:

```sh
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.

<Tabs>
<Tab name="Base Stack (4 services)">

```yaml
services:
  wp:
    image: wordpress:php8.3
    restart: unless-stopped
    ports:
      - 5010:80
    volumes:
      - ./config/wp_php.ini:/usr/local/etc/php/conf.d/conf.ini
      - ./wp-app:/var/www/html
    environment:
      WORDPRESS_DB_HOST: wp-db:3306
      WORDPRESS_DB_NAME: "${DB_NAME}"
      WORDPRESS_DB_USER: "${DB_USER}"
      WORDPRESS_DB_PASSWORD: "${DB_PASSWORD}"
      WORDPRESS_TABLE_PREFIX: "wp_"
      WORDPRESS_CONFIG_EXTRA: |
        define('FS_METHOD', 'direct');
    depends_on:
      wp-db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost/wp-admin/install.php"]
      interval: 30s
      timeout: 10s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 512M
        reservations:
          memory: 256M

  wp-db:
    image: mysql:8.4
    volumes:
      - ./db_data:/var/lib/mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}"
      MYSQL_DATABASE: "${DB_NAME}"
      MYSQL_USER: "${DB_USER}"
      MYSQL_PASSWORD: "${DB_PASSWORD}"
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
    deploy:
      resources:
        limits:
          memory: 1G
        reservations:
          memory: 512M

  pma:
    image: phpmyadmin:latest
    ports:
      - 5011:80
    volumes:
      - ./config/pma_php.ini:/usr/local/etc/php/conf.d/conf.ini
      - ./config/pma_config.php:/etc/phpmyadmin/config.user.inc.php
    restart: unless-stopped
    environment:
      PMA_HOST: wp-db
      PMA_PORT: 3306
      MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}"
      UPLOAD_LIMIT: 100M
    depends_on:
      - wp-db

  wp-db-backup:
    image: tiredofit/db-backup:4.1
    volumes:
      - ./backups:/backup
    restart: unless-stopped
    environment:
      DB_TYPE: mysql
      DB_HOST: wp-db
      DB_NAME: "${DB_NAME}"
      DB_USER: "${DB_USER}"
      DB_PASS: "${DB_PASSWORD}"
      DB_BACKUP_INTERVAL: 720
      DB_CLEANUP_TIME: 72000
      CHECKSUM: SHA1
      COMPRESSION: ZSTD
      CONTAINER_ENABLE_MONITORING: "false"
    depends_on:
      - wp-db
```

</Tab>
<Tab name="Full Stack with Redis (5 services)">

```yaml
services:
  wp:
    build:
      context: .
      dockerfile: Dockerfile
    restart: unless-stopped
    ports:
      - 5010:80
    volumes:
      - ./config/wp_php.ini:/usr/local/etc/php/conf.d/conf.ini
      - ./wp-app:/var/www/html
    environment:
      WORDPRESS_DB_HOST: wp-db:3306
      WORDPRESS_DB_NAME: "${DB_NAME}"
      WORDPRESS_DB_USER: "${DB_USER}"
      WORDPRESS_DB_PASSWORD: "${DB_PASSWORD}"
      WORDPRESS_TABLE_PREFIX: "wp_"
      WORDPRESS_CONFIG_EXTRA: |
        define('FS_METHOD', 'direct');
        define('WP_REDIS_HOST', 'redis-wp');
        define('WP_REDIS_PORT', 6379);
        define('WP_REDIS_DATABASE', 0);
    depends_on:
      wp-db:
        condition: service_healthy
      redis-wp:
        condition: service_started
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost/wp-admin/install.php"]
      interval: 30s
      timeout: 10s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 512M
        reservations:
          memory: 256M

  wp-db:
    image: mysql:8.4
    volumes:
      - ./db_data:/var/lib/mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}"
      MYSQL_DATABASE: "${DB_NAME}"
      MYSQL_USER: "${DB_USER}"
      MYSQL_PASSWORD: "${DB_PASSWORD}"
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
    deploy:
      resources:
        limits:
          memory: 1G
        reservations:
          memory: 512M

  pma:
    image: phpmyadmin:latest
    ports:
      - 5011:80
    volumes:
      - ./config/pma_php.ini:/usr/local/etc/php/conf.d/conf.ini
      - ./config/pma_config.php:/etc/phpmyadmin/config.user.inc.php
    restart: unless-stopped
    environment:
      PMA_HOST: wp-db
      PMA_PORT: 3306
      MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}"
      UPLOAD_LIMIT: 100M
    depends_on:
      - wp-db

  wp-db-backup:
    image: tiredofit/db-backup:4.1
    volumes:
      - ./backups:/backup
    restart: unless-stopped
    environment:
      DB_TYPE: mysql
      DB_HOST: wp-db
      DB_NAME: "${DB_NAME}"
      DB_USER: "${DB_USER}"
      DB_PASS: "${DB_PASSWORD}"
      DB_BACKUP_INTERVAL: 720
      DB_CLEANUP_TIME: 72000
      CHECKSUM: SHA1
      COMPRESSION: ZSTD
      CONTAINER_ENABLE_MONITORING: "false"
    depends_on:
      - wp-db

  redis-wp:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - ./redis_data:/data
    deploy:
      resources:
        limits:
          memory: 256M
        reservations:
          memory: 128M
```

</Tab>
</Tabs>

<Notice type="error" title="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.
</Notice>

<Notice type="info" title="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.
</Notice>

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](https://www.bitdoze.com/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`:

```sh
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.

<Notice type="success" title="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.
</Notice>

Add these to your `.env` file:

```sh
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:

<Button text="Generate WP Salts →" link="https://api.wordpress.org/secret-key/1.1/salt/" variant="outline" color="blue" size="md" icon="arrow-right" />

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](https://www.bitdoze.com/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:

```sh
docker compose up -d
```

Watch the logs to catch any startup errors:

```sh
docker compose logs -f
```

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

```sh
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.

<Notice type="info" title="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`.
</Notice>

**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.

<Notice type="info" title="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.
</Notice>

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

<Picture src={imag1} alt="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:

```sh
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](https://www.bitdoze.com/traefik-proxy-docker/) if you prefer managing SSL yourself, or [CloudPanel with Dockge](https://www.bitdoze.com/cloudpanel-setup-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:

<Picture src={imag2} alt="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:

```ini
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:

```sh
docker compose restart wp
```

<Notice type="info" title="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`
</Notice>

## 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.

<Notice type="warning" title="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`.
</Notice>

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

```sh
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
```

<Notice type="info" title="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).
</Notice>

For full site backups (files + database), pair this with a WordPress backup plugin — see [Best Free WordPress Backup Plugins](https://www.bitdoze.com/best-free-wordpress-backup-plugins/) for options that handle themes, plugins, and uploads too.

### 11. How to restore a database backup

<Notice type="warning" title="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.
</Notice>

To restore from a compressed backup:

```sh
# 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:

```sh
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

<Notice type="error" title="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."
</Notice>

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

<Tabs>
<Tab name="Custom Dockerfile (Recommended)">

Create a `Dockerfile` in the same directory as your `compose.yaml`:

```dockerfile
FROM wordpress:php8.3
RUN pecl install redis && docker-php-ext-enable redis
```

Then change the `wp` service in your compose file from `image: wordpress:php8.3` to:

```yaml
wp:
  build:
    context: .
    dockerfile: Dockerfile
```

The `WORDPRESS_CONFIG_EXTRA` in the full stack compose file (Tab 2 in Step 3) already includes the Redis host and port defines. Rebuild with:

```sh
docker compose up -d --build
```

</Tab>
<Tab name="Pre-built Image">

Use `fazalfarhan01/wordpress-redis` — a community image that bundles the Redis extension:

```yaml
wp:
  image: fazalfarhan01/wordpress-redis:php8.3
```

No Dockerfile needed, but you're trusting a third-party image. Check the Docker Hub page for the latest tags.

</Tab>
<Tab name="Predis (No Build Required)">

The [Redis Object Cache](https://wordpress.org/plugins/redis-cache/) plugin supports the `Predis` pure-PHP library as an alternative to the `phpredis` extension. Install it via Composer inside the container:

```sh
docker exec wp bash -c "curl -sS https://getcomposer.org/installer | php && php composer.phar require predis/predis"
```

Then in the plugin settings, switch the client to "Predis." This is slower than the native extension but requires no image customization.

</Tab>
</Tabs>

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](https://www.bitdoze.com/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:

```sh
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:

```sh
docker run -it --rm \
  --volumes-from wp \
  --network container:wp \
  wordpress:cli \
  wp plugin list
```

<Accordion label="Common WP-CLI commands" group="wpcli" expanded="false">

**Plugin management:**
```sh
# 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:**
```sh
# 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:**
```sh
# 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:**
```sh
# 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
```

</Accordion>

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

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

<Notice type="success" title="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.
</Notice>

Monitor your server to detect anomalies early — see [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/) 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:

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

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

<Notice type="info" title="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.
</Notice>

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

```yaml
wp:
  image: wordpress:php8.3:6.8
```

Add to `WORDPRESS_CONFIG_EXTRA`:

```php
define('WP_AUTO_UPDATE_CORE', false);
```

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

```sh
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:

```sh
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 monitoring** — [monitor your server and Docker resources](https://www.bitdoze.com/sever-monitoring/) 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](https://www.bitdoze.com/best-self-hosted-panels/) for options beyond Dockge.
- **Master Docker commands** — bookmark these [essential Docker commands](https://www.bitdoze.com/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.