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.
| Component | Tag | Notes |
|---|---|---|
| WordPress | wordpress:7.0.2-php8.5-apache | or 7.0.2-php8.5-fpm-alpine |
| PHP | 8.5 | bundled in the WordPress image |
| MariaDB | mariadb:12.3 | lts currently resolves to 12.3.2 |
| Redis | redis:8.10-alpine | server only, no persistence |
| nginx | nginx:1.30.4-alpine | only if you run it as a container |
| Redis Object Cache | 2.8.0 | plugin, bundles Predis 2.4.0 |
| phpredis | 6.3.0 | optional, see step 3 |
| WP-CLI | wordpress:cli-php8.5 | sidecar, 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 :6379The 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 containersStart 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.
sudo mkdir -p /srv/wp && cd /srv/wpStep 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-contentmkdir -p config backup data/db data/wordpressNo Redis data directory. More on that in step 4.
Step 2: .env
# 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=wpGenerate the passwords rather than typing them:
openssl rand -base64 24Then 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 fallbackUse 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):
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/pearFPM on Alpine (build tools are not preinstalled, so add and drop them):
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/pearThen 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
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 = 60A 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.
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/htmlNo 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:
sudo apt install nginx certbot python3-certbot-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;
}
}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.comX-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:
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:roPort 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:
# /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:
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 alreadyWith 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
docker compose up -d
docker compose logs -f wordpressRun 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.phpCheck the stack answers on loopback before involving nginx. This is the fastest way to find out which half is broken:
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.phpThen 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:
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:
docker compose run --rm wpcli plugin install redis-cache --activate
docker compose run --rm wpcli redis enable
docker compose run --rm wpcli redis statusredis status tells you which client it picked:
Status: Connected
Client: Predis (v2.4.0)
Drop-in: ValidClient: 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:
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:611What 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 --> renderA 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.
#!/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:
gunzip -c backup/db-20260802-1130.sql.gz | \
docker compose exec -T db mariadb -u root -p"$MARIADB_ROOT_PASSWORD" wordpressOnly 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:
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 --allMariaDB 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
| Symptom | Likely 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 nginx | nothing listening on 127.0.0.1:8080. Check docker compose ps and ss -ltnp | grep 8080 |
| "Primary script unknown", route B | SCRIPT_FILENAME still points at the host path. Override it to /var/www/html$fastcgi_script_name |
| 403 from nginx on static files, route B | nginx 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 redirect | same cause as above |
| Upload limit still shows 2M | ini not mounted, or mounted outside /usr/local/etc/php/conf.d/. Check docker compose exec wordpress php -i | grep upload_max |
| Upload dies partway through | client_max_body_size in nginx is smaller than post_max_size in PHP |
| White screen after enabling the object cache | stale wp-content/object-cache.php. Delete it, then wpcli redis enable again |
| Plugin active, but nothing in Redis | the 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 Predis | the container is still running the old image. docker compose up -d --force-recreate wordpress |
| Plugin install asks for FTP credentials | FS_METHOD not set to direct, or wp-content is not writable by the PHP user |
| New image pulled, WordPress version unchanged | expected. 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.