Skip to content

Self-hosting WordPress with Docker: Apache or PHP-FPM, plus a Redis object cache

Every WordPress-on-Docker tutorial stops at "it boots." This one goes further: pinned versions, a real object cache with the compiled PHP extension, a reverse proxy that actually terminates TLS, and the two or three behaviours of the official image that will bite you on day 30 rather than day one.

Versions below were current when I checked, on 2 August 2026. Pin them. Do not ship :latest.

ComponentTagNotes
WordPresswordpress:7.0.2-php8.5-apacheor 7.0.2-php8.5-fpm-alpine
PHP8.5bundled in the WordPress image
MariaDBmariadb:12.3lts currently resolves to 12.3.2
Redisredis:8.10-alpineserver only, no persistence
nginxnginx:1.30.4-alpineonly if you run it as a container
Redis Object Cache2.8.0plugin, bundles Predis 2.4.0
phpredis6.3.0optional, see step 3
WP-CLIwordpress:cli-php8.5sidecar, run on demand

WordPress 7.1 lands 19 August 2026, so expect the 7.0.2 part of that tag to move soon.

The stack

Three containers do the work. Nothing is reachable from outside except the one port you publish, and you publish it on loopback only.

   [ nginx, terminating TLS ]
              |
              v
      127.0.0.1:8080
              |
   ===========|=================== docker
              v
        [ wp_app ]  wordpress + php
              |
              |  service names on wp_default
     +--------+--------+
     |                 |
 [ wp_db ]      [ wp_redis ]
   :3306             :6379

The database and Redis never publish a port. They are reachable from the other containers by service name, db and redis, and from nowhere else.

Two decisions

How PHP runs. Apache with mod_php in one container, or PHP-FPM. Apache is one image, one process, no extra config file. FPM gives you a separate web server that can serve static files without touching PHP, and costs you an nginx config you now own.

Where nginx runs. On the host, or as a fourth container.

                     edge = nginx on the host      edge = nginx in compose
  A) apache image    publish 127.0.0.1:8080        proxy_pass wordpress:80
                     proxy_pass to it              4 containers
                     3 containers  <-- start here

  B) fpm image       publish 127.0.0.1:9000        fastcgi_pass wordpress:9000
                     fastcgi_pass to it            4 containers
                     3 containers

Start with A on a host nginx. It is the fewest moving parts, and certbot on the host handles renewals without you thinking about it. Go to B if you want per-location caching and rate limiting, or you are serving enough static files that keeping them out of PHP matters.

Steps 1 through 5 are identical for every combination.

Prerequisites

Docker Engine 27 or newer with the Compose v2 plugin. That is the whole list.

Put the project somewhere nginx can traverse. If the host nginx will read ./data/wordpress directly (route B), /root/wp will not work no matter what you chmod, because /root is 700. Use /srv/wp or /opt/wp.

bash
sudo mkdir -p /srv/wp && cd /srv/wp

Step 1: layout

/srv/wp/
├─ .env                  # secrets and sizing
├─ compose.yaml
├─ Dockerfile            # adds phpredis
├─ config/
│  ├─ php.ini            # PHP + opcache overrides
│  └─ nginx.conf         # only if nginx runs as a container
├─ backup/
└─ data/
   ├─ db/                # MariaDB datadir
   └─ wordpress/         # core + wp-content
bash
mkdir -p config backup data/db data/wordpress

No Redis data directory. More on that in step 4.

Step 2: .env

bash
# Database
MARIADB_ROOT_PASSWORD=change_me_root
MARIADB_DATABASE=wordpress
MARIADB_USER=wordpress
MARIADB_PASSWORD=change_me_user
WORDPRESS_TABLE_PREFIX=wp_

# Where the stack listens on the host (loopback only)
HTTP_BIND=127.0.0.1:8080

# WordPress memory
WP_MEMORY_LIMIT=256M
WP_MAX_MEMORY_LIMIT=512M

# Redis
REDIS_MAXMEMORY=256mb
WP_REDIS_PREFIX=wp

Generate the passwords rather than typing them:

bash
openssl rand -base64 24

Then chmod 600 .env and add it to .gitignore.

WP_REDIS_PREFIX matters the moment you run a second WordPress site. Two sites sharing one Redis with no prefix will read each other's cache keys, and the symptoms look like haunting.

Step 3: choose a Redis client

You can skip this step. It exists because there are two ways for PHP to talk to Redis, and a lot of guides present the harder one as mandatory.

The official WordPress image ships bcmath, exif, gd, intl, mysqli, zip and imagick. It does not ship the Redis extension. That does not matter, because the Redis Object Cache plugin bundles Predis, a Redis client written in plain PHP, and uses it automatically when the extension is absent.

                     phpredis (PECL)        Predis (bundled)
  client language    C extension            PHP
  in stock image     no, must build         yes, ships with plugin
  custom Dockerfile  required               not needed
  rebuild on every   yes                    no
    upstream update
  plugin support     yes                    yes, the default fallback

Use the stock image. Get the cache working, then open the plugin's Metrics tab and look at the milliseconds it reports per request. If that number bothers you, come back and compile the extension. On a brochure site you will not be able to tell the difference. On WooCommerce with forty plugins making several hundred wp_cache_get calls per page, you will.

The cost of the custom image is not the Dockerfile, it is that you now rebuild every time upstream ships a WordPress or PHP security fix. Know what you are buying.

If you want it, add a Dockerfile next to compose.yaml.

Apache (Debian base, PHPIZE_DEPS already installed):

dockerfile
FROM wordpress:7.0.2-php8.5-apache

RUN set -eux; \
    pecl install redis-6.3.0; \
    docker-php-ext-enable redis; \
    rm -rf /tmp/pear

FPM on Alpine (build tools are not preinstalled, so add and drop them):

dockerfile
FROM wordpress:7.0.2-php8.5-fpm-alpine

RUN set -eux; \
    apk add --no-cache --virtual .build-deps $PHPIZE_DEPS; \
    pecl install redis-6.3.0; \
    docker-php-ext-enable redis; \
    apk del .build-deps; \
    rm -rf /tmp/pear

Then swap image: for build: in the next step. The plugin picks the extension up on its own; you can also pin the choice with define('WP_REDIS_CLIENT', 'phpredis'); or 'predis'.

Step 4: config/php.ini

ini
upload_max_filesize = 64M
post_max_size = 64M
memory_limit = 256M
max_execution_time = 300
max_input_time = 300
max_input_vars = 3000

; the base image sets 4000 files and revalidate_freq=2,
; both too tight for a plugin-heavy production site
opcache.memory_consumption = 192
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.revalidate_freq = 60

A common pattern is to feed these in as environment variables and rewrite them into an ini file by overriding the container's command. Skip it. Overriding command means you now maintain a shell one-liner that has to end in docker-entrypoint.sh apache2-foreground, and a typo there costs you the entrypoint that unpacks WordPress and writes wp-config.php. A mounted file is one line of YAML and survives every image upgrade.

The zz- prefix on the mount target makes PHP load it last, so it wins against the image's own opcache-recommended.ini.

Step 5: compose.yaml

This is route A: Apache, published on loopback for a host nginx to proxy.

yaml
name: wp

services:
  db:
    image: mariadb:12.3
    container_name: wp_db
    restart: unless-stopped
    environment:
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:?missing in .env}
      MARIADB_DATABASE: ${MARIADB_DATABASE:-wordpress}
      MARIADB_USER: ${MARIADB_USER:-wordpress}
      MARIADB_PASSWORD: ${MARIADB_PASSWORD:?missing in .env}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - ./data/db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 12
      start_period: 60s

  redis:
    image: redis:8.10-alpine
    container_name: wp_redis
    restart: unless-stopped
    command: >
      redis-server
      --save ""
      --appendonly no
      --maxmemory ${REDIS_MAXMEMORY:-256mb}
      --maxmemory-policy allkeys-lru
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5

  wordpress:
    image: wordpress:7.0.2-php8.5-apache
    # only if you compiled phpredis in step 3:
    # build:
    #   context: .
    #   dockerfile: Dockerfile
    # image: wp-wordpress:local
    container_name: wp_app
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    ports:
      - "${HTTP_BIND:-127.0.0.1:8080}:80"
    environment:
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_NAME: ${MARIADB_DATABASE:-wordpress}
      WORDPRESS_DB_USER: ${MARIADB_USER:-wordpress}
      WORDPRESS_DB_PASSWORD: ${MARIADB_PASSWORD:?missing in .env}
      WORDPRESS_TABLE_PREFIX: ${WORDPRESS_TABLE_PREFIX:-wp_}
      WORDPRESS_CONFIG_EXTRA: |
        define('WP_REDIS_HOST', 'redis');
        define('WP_REDIS_PORT', 6379);
        define('WP_REDIS_PREFIX', '${WP_REDIS_PREFIX:-wp}');
        define('WP_REDIS_DATABASE', 0);
        define('WP_REDIS_TIMEOUT', 1);
        define('WP_REDIS_READ_TIMEOUT', 1);
        define('WP_CACHE', true);
        define('WP_MEMORY_LIMIT', '${WP_MEMORY_LIMIT:-256M}');
        define('WP_MAX_MEMORY_LIMIT', '${WP_MAX_MEMORY_LIMIT:-512M}');
        define('DISALLOW_FILE_EDIT', true);
        define('FS_METHOD', 'direct');
    volumes:
      - ./data/wordpress:/var/www/html
      - ./config/php.ini:/usr/local/etc/php/conf.d/zz-wordpress.ini:ro

  wpcli:
    image: wordpress:cli-php8.5
    profiles: [tools]
    user: "33:33"
    depends_on:
      db:
        condition: service_healthy
    environment:
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_NAME: ${MARIADB_DATABASE:-wordpress}
      WORDPRESS_DB_USER: ${MARIADB_USER:-wordpress}
      WORDPRESS_DB_PASSWORD: ${MARIADB_PASSWORD:?missing in .env}
      WORDPRESS_TABLE_PREFIX: ${WORDPRESS_TABLE_PREFIX:-wp_}
    volumes:
      - ./data/wordpress:/var/www/html

No networks: block. Compose puts every service on a project network called wp_default and gives each one a DNS name matching its service name, which is why WORDPRESS_DB_HOST is db:3306. Declaring networks by hand adds nothing here.

Five things worth calling out.

The bind address is not optional. Writing "8080:80" publishes on every interface, and Docker inserts its own iptables rules ahead of yours, so ufw will happily report that port 8080 is blocked while the whole internet reads your WordPress over plain HTTP. 127.0.0.1:8080:80 is the fix.

${VAR:?message} aborts docker compose up with your message when a variable is missing. Without it, a typo in .env gets you a database with an empty password.

Redis has no volume, --save "", --appendonly no. An object cache is derived data. Persisting it buys you a warm cache after a restart and costs you fork-based disk writes forever. A cold cache refills in seconds. If you later use Redis for sessions or a queue, that calculus changes.

--maxmemory-policy allkeys-lru is the important flag. Redis defaults to noeviction, which means that once the cache fills, writes start failing and WordPress starts throwing cache errors instead of quietly evicting the coldest keys.

The healthchecks run every 10s, not every 120s. depends_on: service_healthy waits for a passing check, so a slow interval turns directly into slow startup. Two minutes of the stack looking broken is not a virtue.

If you carried over a top-level volumes: block declaring db_data and friends, delete it. Those named volumes are unused when you bind-mount ./data/db.

Step 6: nginx on the host

Install nginx and certbot, then drop in a vhost:

bash
sudo apt install nginx certbot python3-certbot-nginx
nginx
# /etc/nginx/sites-available/example.com
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    client_max_body_size 64m;    # keep in sync with post_max_size

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_read_timeout 300s;
    }
}
bash
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d example.com -d www.example.com

X-Forwarded-Proto is the header that matters. WordPress decides whether to emit http:// or https:// URLs based on it, and the official image's wp-config already reads it (see step 8). Drop that header and you get mixed-content warnings, or a redirect loop.

client_max_body_size and post_max_size disagreeing is the single most common cause of "my 30MB video upload dies at 90%." nginx rejects the request body before PHP ever sees it, so raising the PHP limit alone gets you nowhere.

Certbot rewrites the file to add a 443 block and a redirect, and installs a renewal timer. Check it with systemctl list-timers | grep certbot.

Step 6, route B: PHP-FPM

Two changes to the compose file. Point the build at the FPM Dockerfile, and publish FastCGI instead of HTTP:

yaml
  wordpress:
    image: wordpress:7.0.2-php8.5-fpm-alpine
    # or build: from the fpm-alpine Dockerfile in step 3
    container_name: wp_app
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    ports:
      - "127.0.0.1:9000:9000"         # FastCGI, loopback only, no exceptions
    environment:
      # unchanged from route A
      ...
    volumes:
      - ./data/wordpress:/var/www/html
      - ./config/php.ini:/usr/local/etc/php/conf.d/zz-wordpress.ini:ro

Port 9000 speaks FastCGI, not HTTP. FastCGI has no authentication and no TLS, and anything that can reach it can execute arbitrary PHP. If you ever find yourself typing 9000:9000 without the loopback prefix, stop.

Now the host nginx serves the static files itself and hands only PHP to the container:

nginx
# /etc/nginx/sites-available/example.com
server {
    listen 80;
    server_name example.com;

    root /srv/wp/data/wordpress;      # the HOST path
    index index.php;
    client_max_body_size 64m;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        try_files $uri =404;
        include fastcgi_params;
        fastcgi_pass 127.0.0.1:9000;

        # the container sees these files at /var/www/html, not at the host path
        fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT   /var/www/html;
        fastcgi_param PATH_INFO       $fastcgi_path_info;

        fastcgi_read_timeout 300;
        fastcgi_buffers 16 16k;
        fastcgi_buffer_size 32k;
    }

    location ~* \.(js|css|png|jpe?g|gif|ico|svg|webp|avif|woff2?)$ {
        expires 30d;
        add_header Cache-Control "public";
        access_log off;
        try_files $uri =404;
    }

    # never execute PHP that arrived through the media uploader
    location ^~ /wp-content/uploads/ {
        location ~ \.php$ { deny all; }
    }

    location = /favicon.ico { log_not_found off; access_log off; }
    location = /robots.txt  { log_not_found off; access_log off; allow all; }
    location ~ /\.          { deny all; }
    location = /xmlrpc.php  { deny all; }   # drop this line if you use the WP mobile app
}

Those two fastcgi_param overrides are the entire trick, and skipping them is why this setup usually gets abandoned. nginx computes SCRIPT_FILENAME from its own root, which is /srv/wp/data/wordpress. PHP-FPM then looks for that path inside the container, where it does not exist, and returns "Primary script unknown" or a blank white page with nothing useful in the nginx log. The two filesystems are the same files at different paths, and only you know the mapping.

Two smaller notes on this route. try_files $uri =404 inside the PHP location closes the path-info code-execution hole that stock configs from 2013 still carry. And HTTPS detection works differently here: there is no reverse proxy, so no X-Forwarded-Proto. Debian's fastcgi_params passes HTTPS=on directly once certbot has set up the TLS block, and WordPress picks that up on its own.

Step 6, alternative: nginx as a container

Use this if the host has no nginx and you would rather keep everything in Docker. Add a fourth service and stop publishing a port from wordpress:

yaml
  nginx:
    image: nginx:1.30.4-alpine
    container_name: wp_nginx
    restart: unless-stopped
    depends_on: [wordpress]
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./data/wordpress:/var/www/html:ro   # only needed for the fpm variant
      - ./config/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./config/certs:/etc/nginx/certs:ro  # if you have certs already

With the Apache image, config/nginx.conf is the proxy config from step 6 with proxy_pass http://wordpress:80; instead of 127.0.0.1:8080. With the FPM image, it is the FastCGI config with fastcgi_pass wordpress:9000;, root /var/www/html;, and no path remapping, because inside Docker both containers agree on the path.

Be clear-eyed about TLS here. A container nginx makes sense when you already have certificates on disk, or when the site is internal and runs on plain HTTP, or when something upstream terminates TLS for you. Getting automatic Let's Encrypt renewal working inside this container means a certbot sidecar, a shared webroot, and a reload hook. If that is not a project you want, use the host nginx, or swap this service for Caddy or Traefik, both of which do ACME on their own.

Step 7: first boot

bash
docker compose up -d
docker compose logs -f wordpress

Run docker compose build first only if you chose the custom image in step 3.

You are watching for two lines:

WordPress not found in /var/www/html - copying now...
No 'wp-config.php' found in /var/www/html, but 'WORDPRESS_...' variables supplied; copying ...

Roughly how the startup sequences:

t=0s    db starting        health: starting
t~20s   db healthy         healthcheck.sh --connect passes
t~20s   redis healthy
t~21s   wordpress starts   unpack core -> generate wp-config.php

Check the stack answers on loopback before involving nginx. This is the fastest way to find out which half is broken:

bash
curl -I http://127.0.0.1:8080          # route A
# HTTP/1.1 302 Found
# Location: http://127.0.0.1:8080/wp-admin/install.php

Then open the domain and finish the five-minute install.

Step 8: what the image writes into wp-config.php

Three facts worth knowing before you need them.

The salts are generated once. The auth keys and salts come from /dev/urandom at the moment the file is created, and are written into it literally. They are not read from the environment on each boot. To manage them yourself, set WORDPRESS_AUTH_KEY, WORDPRESS_SECURE_AUTH_KEY, WORDPRESS_LOGGED_IN_KEY, WORDPRESS_NONCE_KEY, WORDPRESS_AUTH_SALT, WORDPRESS_SECURE_AUTH_SALT, WORDPRESS_LOGGED_IN_SALT and WORDPRESS_NONCE_SALT before the first start. Changing them later logs everyone out, which is exactly what you want after a breach.

Everything else is read live. WORDPRESS_CONFIG_EXTRA and the WORDPRESS_DB_* variables go through a getenv_docker() helper on every request, and the extra block runs through eval(). So editing WORDPRESS_CONFIG_EXTRA and running docker compose up -d takes effect immediately. You do not need to delete wp-config.php.

The reverse-proxy fix is already there. No need to add it:

php
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strpos($_SERVER['HTTP_X_FORWARDED_PROTO'], 'https') !== false) {
    $_SERVER['HTTPS'] = 'on';
}

If you still get an infinite HTTPS redirect, the problem is upstream: nginx is not sending the header.

Step 9: turn on the object cache

Three commands, on the stock image:

bash
docker compose run --rm wpcli plugin install redis-cache --activate
docker compose run --rm wpcli redis enable
docker compose run --rm wpcli redis status

redis status tells you which client it picked:

Status:      Connected
Client:      Predis (v2.4.0)
Drop-in:     Valid

Client: PhpRedis (v6.3.0) means the extension is present and in use. Either line is a working cache. Confirm the extension separately with docker compose exec wordpress php -m | grep -x redis, which prints nothing on the stock image, as expected.

redis enable drops wp-content/object-cache.php into place. That file is the whole mechanism. Without it, WP_CACHE and the WP_REDIS_* constants do nothing at all. Installing and activating the plugin is not enough on its own; the drop-in has to exist, which is also why a failed write to wp-content shows up as a cache that is silently doing nothing.

The uid on the wpcli service is not decoration. The CLI image is Alpine-based, where www-data is uid 82. The Apache WordPress image is Debian-based, where www-data is uid 33. Run WP-CLI with the wrong uid and every file it writes becomes unreadable to PHP. Match your base image:

wordpress:...-apache      Debian    www-data = 33   -> user: "33:33"
wordpress:...-fpm         Debian    www-data = 33   -> user: "33:33"
wordpress:...-fpm-alpine  Alpine    www-data = 82   -> user: "82:82"

Verify keys are landing in Redis, then load the site a few times and watch the numbers climb:

bash
docker compose exec redis redis-cli info keyspace
# db0:keys=412,expires=7,avg_ttl=0

docker compose exec redis redis-cli info stats | grep keyspace
# keyspace_hits:18342
# keyspace_misses:611

What the request path looks like once it works:

  request
     |
     v
 [ nginx ] -> [ apache | php-fpm ]
                  |
                  v
          [ PHP: wp_cache_get ]
                  |
                  +-- HIT  --> render from Redis, ~2 SQL queries
                  |
                  +-- MISS --> MariaDB --> wp_cache_set --> render

A hit rate under about 90% after a day of traffic usually means an eviction problem, not a WordPress problem. Check whether used_memory has pinned itself to maxmemory and raise REDIS_MAXMEMORY.

An object cache is not a page cache. It removes repeated database queries from every request, including logged-in and admin requests. It does not stop WordPress from booting PHP. Full-page caching belongs in nginx or a plugin, and it is a separate decision.

Step 10: backups

Do not tar a running MariaDB datadir. ./data/db is being written to while the archive is created, and the copy you get back may or may not be consistent. Dump the database and archive the files separately.

bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
source .env
stamp=$(date +%Y%m%d-%H%M)

docker compose exec -T db \
  mariadb-dump --single-transaction --quick --no-tablespaces \
  -u root -p"$MARIADB_ROOT_PASSWORD" "$MARIADB_DATABASE" \
  | gzip > "backup/db-$stamp.sql.gz"

tar -czf "backup/files-$stamp.tar.gz" -C data/wordpress wp-content

find backup -name '*.gz' -mtime +14 -delete

--single-transaction gives you a consistent snapshot on InnoDB without locking writers. In MariaDB 11 and later the binary is mariadb-dump; mysqldump still exists as a symlink but will not forever.

Restore:

bash
gunzip -c backup/db-20260802-1130.sql.gz | \
  docker compose exec -T db mariadb -u root -p"$MARIADB_ROOT_PASSWORD" wordpress

Only wp-content is worth archiving. Core files come from the image, and wp-config.php is reproducible from your compose file, apart from those salts. Back the salts up once.

Step 11: updating

The one that surprises people. Look at the entrypoint's guard:

bash
if [ ! -e index.php ] && [ ! -e wp-includes/version.php ]; then
    echo >&2 "WordPress not found in $PWD - copying now..."

Core files are copied out of the image only when the directory is empty. Once ./data/wordpress contains an install, pulling wordpress:7.1-php8.5-apache gives you the newer PHP build, the newer Apache, and the newer extensions, and leaves your WordPress core files exactly where they were.

So updates split in two:

Image tag  -> PHP, Apache/FPM, extensions, base OS
             docker compose pull && docker compose up -d
             (add `docker compose build --pull` if you use the custom image)

WP core    -> dashboard, or:
             docker compose run --rm wpcli core update
             docker compose run --rm wpcli core update-db
             docker compose run --rm wpcli plugin update --all

MariaDB majors need MARIADB_AUTO_UPGRADE: "1", already in the compose file above. It runs mariadb-upgrade when it detects an older datadir. Take a dump first anyway, and move one major at a time.

Troubleshooting

SymptomLikely cause
"Error establishing a database connection" on first boot./data/db was initialised with a different password on an earlier run. The MariaDB image only applies credentials to an empty datadir. Wipe it or reset the password by hand
502 Bad Gateway from the host nginxnothing listening on 127.0.0.1:8080. Check docker compose ps and ss -ltnp | grep 8080
"Primary script unknown", route BSCRIPT_FILENAME still points at the host path. Override it to /var/www/html$fastcgi_script_name
403 from nginx on static files, route Bnginx cannot traverse to ./data/wordpress. Move the project out of a 700 parent directory
Site loads but every URL is http://X-Forwarded-Proto is not being set by nginx
Infinite HTTPS redirectsame cause as above
Upload limit still shows 2Mini not mounted, or mounted outside /usr/local/etc/php/conf.d/. Check docker compose exec wordpress php -i | grep upload_max
Upload dies partway throughclient_max_body_size in nginx is smaller than post_max_size in PHP
White screen after enabling the object cachestale wp-content/object-cache.php. Delete it, then wpcli redis enable again
Plugin active, but nothing in Redisthe drop-in was never written. redis status will say the drop-in is not valid. Check that wp-content is writable
redis status says "Not connected"wrong WP_REDIS_HOST, or the containers are in different Compose projects
Built the custom image but redis status still says Predisthe container is still running the old image. docker compose up -d --force-recreate wordpress
Plugin install asks for FTP credentialsFS_METHOD not set to direct, or wp-content is not writable by the PHP user
New image pulled, WordPress version unchangedexpected. Update core through WP-CLI or the dashboard
Cache hit rate stuck around 60%Redis is at maxmemory and evicting. Raise REDIS_MAXMEMORY

What to do next

Put the backup script from step 10 on a cron entry and test the restore into a scratch directory before you need it. Then measure: install Query Monitor, note the query count on your slowest page with the object cache disabled, enable it, and look again. That number is the only proof any of this worked.

After that, the gap worth closing is knowing when the box is unhappy before a visitor tells you. Prometheus, Node Exporter, and Grafana sets that up on the same kind of single server, and the Node Exporter dashboard alone will tell you whether Redis is really sized right.

Self-hosting is most of what I do: 30+ open-source platforms deployed and maintained for clients over the last decade. If you would rather not run this yourself, get in touch.