So there I was, staring at yet another WordPress site that had quietly grown from a simple blog into a real business. Traffic was good. Sales were happening. The owner was excited. And then, predictably, came the question: “Can you make it run like a proper system? I don’t want surprises.” I smiled because I’ve been there—sites stitched together with a mix of shared hosting, plugin updates at 2 a.m., and backups that might work if we’re lucky. Here’s the thing: it doesn’t have to be that way.
If you’ve ever wanted WordPress to feel solid—like a little service running in your own private cloud—Docker Compose is your friend. It lets you spin up Nginx, PHP‑FPM, MariaDB, and Redis as small, predictable pieces that fit together cleanly. Add persistent volumes so nothing gets lost, schedule backups, and use a sane update flow, and suddenly WordPress stops being a stress magnet.
In this guide, we’ll build a friendly, production‑tinted stack: Nginx in front, WordPress (PHP‑FPM) in the middle, MariaDB for data, Redis for speed, volumes for persistence, auto‑backups that actually run, and an update flow that won’t make your palms sweat. I’ll show you code, but more importantly I’ll share the little choices that keep this simple and calm.
İçindekiler
- 1 Why Docker Compose for WordPress just… works
- 2 The simple mental model: what’s persistent and what’s ephemeral
- 3 Let’s write docker-compose.yml (friendly, but production‑aware)
- 4 Nginx that plays nice with PHP‑FPM (and doesn’t get in the way)
- 5 MariaDB and Redis: fast where it matters, durable where it must
- 6 Persistent volumes, explained like we’re on a whiteboard
- 7 Auto‑backups you can actually trust
- 8 A friendly, low‑risk update flow
- 9 What can go wrong (and how I recover without drama)
- 10 Security and stability touches that matter
- 11 A tiny performance sprinkle, only when it pays
- 12 WP‑CLI quick wins you’ll use all the time
- 13 Putting it all together (and where to go next)
- 14 Step‑by‑step: your first deployment checklist
- 15 Wrap‑up: the calm, repeatable way to run WordPress
Why Docker Compose for WordPress just… works
I used to run WordPress directly on the host. It’s fine—until it isn’t. One day you’re upgrading PHP and breaking extensions, the next day you’re untangling permissions. With Docker Compose, you describe your world declaratively and press “go.” Think of it like packing cubes in a suitcase: one for the web server, one for PHP, one for the database, one for caching. If something breaks, you replace the cube, not the suitcase.
What I love about Compose is that it mirrors how you think. You say “Nginx talks to PHP‑FPM here,” “MariaDB stores data here,” “Redis caches things over there,” “this folder holds uploads forever,” and Compose wires it up consistently every time. You get three crucial things: a repeatable environment, clear boundaries, and a small, obvious surface for automation—like scheduled backups.
If you’ve never touched it, the Docker Compose quickstart is a nice primer. We’ll go deeper, but that page helps you recognize the moving parts.
The simple mental model: what’s persistent and what’s ephemeral
Before we open any editor, let’s get one decision right: what needs to persist?
The durable stuff is easy to name. Your MariaDB data—posts, users, settings—must persist. Your WordPress uploads—images, PDFs, media—must persist. Your plugins and themes should persist too, because site owners update them from the dashboard. Everything else can be ephemeral. Nginx containers can come and go. PHP‑FPM containers can be replaced on updates. Redis can be either; for WordPress object caching I often treat Redis as expendable (it repopulates), but I’ll show you a persistent option if you want AOF or RDB for resilience.
So the plan is: persistent volumes for MariaDB and wp-content. Optional persistence for Redis. Read‑only configuration mounts for Nginx and PHP ini files. And a backup volume to hold compressed archives while we sync them off‑box.
Let’s write docker-compose.yml (friendly, but production‑aware)
Here’s a Compose file I’ve used many times. It’s trim, but it has the little things that make life easier: healthchecks, a stable network, named volumes, and clear mounts for configuration and content.
version: "3.9"
services:
nginx:
image: nginx:1.25-alpine
depends_on:
wordpress:
condition: service_healthy
ports:
- "80:80"
# In production, you’ll terminate TLS on 443 here
# - "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- wp_content:/var/www/html/wp-content:ro
# Optional: if you manage full core as a volume, mount ./html too
# - ./html:/var/www/html
networks:
- wp
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost"]
interval: 30s
timeout: 5s
retries: 3
wordpress:
image: wordpress:php8.2-fpm
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_USER: wpuser
WORDPRESS_DB_PASSWORD: supersecret
WORDPRESS_DB_NAME: wpdb
# Optional hardening
PHP_MEMORY_LIMIT: 256M
volumes:
- wp_content:/var/www/html/wp-content
- ./php/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- wp
healthcheck:
test: ["CMD-SHELL", "php-fpm-healthcheck || exit 1"]
interval: 30s
timeout: 5s
retries: 5
db:
image: mariadb:10.11
environment:
MARIADB_DATABASE: wpdb
MARIADB_USER: wpuser
MARIADB_PASSWORD: supersecret
MARIADB_ROOT_PASSWORD: rootsecret
command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"]
volumes:
- db_data:/var/lib/mysql
networks:
- wp
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
# If you prefer Redis as ephemeral cache only, drop the volume and AOF
volumes:
- redis_data:/data
networks:
- wp
backup:
image: alpine:3.20
depends_on:
db:
condition: service_healthy
volumes:
- db_data:/var/lib/mysql:ro
- wp_content:/data/wp-content:ro
- backups:/backups
- ./backup/run.sh:/backup/run.sh:ro
- ./backup/crontab:/etc/crontabs/root:ro
environment:
DB_HOST: db
DB_NAME: wpdb
DB_USER: wpuser
DB_PASSWORD: supersecret
RETENTION_DAYS: 14
command: ["/bin/sh", "-c", "apk add --no-cache mariadb-client tzdata > /dev/null && crond -f -l 8"]
networks:
- wp
networks:
wp:
driver: bridge
volumes:
db_data:
wp_content:
redis_data:
backups:
A couple of quick notes. First, I’m using the official WordPress Docker image in the FPM flavor. That means Nginx will talk to PHP‑FPM over port 9000, and WordPress files are in /var/www/html. Second, by mounting only wp-content, we let the container provide core files while still keeping plugins, themes, and uploads persistent. It’s a sweet spot: you get clean updates with minimal risk of drift. Third, the backup service is just a tiny Alpine container running crond, which makes it dead simple to schedule daily dumps and tarballs.
Nginx that plays nice with PHP‑FPM (and doesn’t get in the way)
I try to keep the Nginx config boring. If a future version of Nginx changes something, boring configs don’t break. This one is compact and friendly, with the usual WordPress rewrites and a well‑behaved fastcgi handler.
# ./nginx/nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
include /etc/nginx/conf.d/*.conf;
}
# ./nginx/conf.d/site.conf
server {
listen 80;
server_name _;
root /var/www/html;
index index.php index.html index.htm;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ .(png|jpg|jpeg|gif|ico|svg|css|js|webp|avif)$ {
access_log off;
expires 30d;
add_header Cache-Control "public, immutable";
}
location ~ .php$ {
try_files $uri =404;
fastcgi_pass wordpress:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
client_max_body_size 32m; # adjust for media uploads
}
In production you’ll likely terminate TLS in this same container or behind a load balancer. I keep certs out of the container image and mount them as secrets or files. If you need a refresher on lean HTTPS choices, I wrote a long, friendly guide to certificate types and when to use them, but we’ll stay focused on Compose here.
MariaDB and Redis: fast where it matters, durable where it must
Let’s talk data shape. MariaDB is your crown jewels—posts, users, settings—the irreplaceable bits. Redis is your assistant—fast, helpful, but replaceable in a pinch. That mental model makes a lot of decisions easier.
MariaDB lives on a named volume called db_data. That’s the disk you back up every day. In the Compose file above, I’m setting utf8mb4 out of the gate to keep emojis and odd characters happy. It costs nothing and saves some pain later. The healthcheck nudges Compose to start services in a sane order.
Redis is there to handle WordPress object caching. The trick to making Redis useful in WordPress is the plugin that wires things up. I usually install the Redis Object Cache plugin, enable it, and point it at redis:6379. In many stacks I leave Redis completely ephemeral and let it repopulate; simple is strong. If you run an unusually heavy admin backend or expensive queries, turning on AOF (as shown) plus a redis_data volume gives you a little more continuity through restarts.
Inside WordPress, you can drop this into wp-config.php to enable object cache cleanly:
define('WP_REDIS_HOST', 'redis');
define('WP_REDIS_PORT', 6379);
// Optional: separate cache prefixes per environment
// define('WP_CACHE_KEY_SALT', 'mysite:');
// Turn on WordPress-level caching
define('WP_CACHE', true);
In my experience, this setup keeps page rendering snappy without getting fancy. If you want to push further, microcaching at Nginx can be magical for anonymous visitors, but that’s a story for another day.
Persistent volumes, explained like we’re on a whiteboard
I get asked a lot: “Do I need to mount the whole /var/www/html or just wp-content?” My rule of thumb is simple. If you manage WordPress core via Docker images (which you do here), mounting only wp-content is the cleanest path. Your core files come from a known image, your plugins/themes/uploads persist, and you can update without dragging a pile of drift along with you.
That’s what the wp_content named volume is doing. Docker stores it wherever your engine keeps volumes, and Compose attaches it to both Nginx (read‑only) and WordPress (read‑write). You could switch to a bind mount (./data/wp-content:/var/www/html/wp-content) if you prefer to see files on the host. I’ve done both, but named volumes are wonderfully boring—you don’t trip over host permissions as much.
For MariaDB, don’t overthink it. A named volume mapped to /var/lib/mysql keeps the data on disk across container recreations. If you ever restore from backup, you’ll stop containers, wipe that directory (carefully), and import your dump into a fresh database.
Redis is optional for persistence. If you expect restarts to be rare and don’t mind a warmup period, skip the volume. If you like belt‑and‑suspenders reliability, keep AOF on and store /data on redis_data.
Auto‑backups you can actually trust
This is the part that used to keep me up at night. A backup that exists is one thing. A backup you’ve restored from is the only thing that counts. The nice thing about Compose is you can turn backups into a tiny sidecar container with a cron schedule, and it just hums along.
Let’s wire a small script and crontab into that backup service.
# ./backup/run.sh
#!/bin/sh
set -eu
STAMP=$(date +"%Y%m%d-%H%M%S")
BACKUP_DIR=/backups/$STAMP
mkdir -p "$BACKUP_DIR"
# Database dump
mysqldump -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" --single-transaction --routines --events "$DB_NAME"
| gzip -9 > "$BACKUP_DIR/db.sql.gz"
# wp-content archive (plugins, themes, uploads)
mkdir -p /tmp/backup
cd /data
tar -czf "$BACKUP_DIR/wp-content.tgz" wp-content
# Optional: keep a small manifest for sanity checks
cat <<EOF > "$BACKUP_DIR/manifest.txt"
DATE=$STAMP
DB_NAME=$DB_NAME
FILES=wp-content.tgz
EOF
# Retention policy
find /backups -maxdepth 1 -type d -mtime +"${RETENTION_DAYS:-14}" -exec rm -rf {} + 2>/dev/null || true
echo "Backup completed: $BACKUP_DIR"
# ./backup/crontab
# Run at 03:17 every day
17 3 * * * /bin/sh /backup/run.sh >/proc/1/fd/1 2>&1
The first time I switched to a cron container like this, I felt silly for not doing it sooner. No dependencies on the host. No mystery systemd timers. Everything is versioned with your stack. If you want offsite copies (please do), you can layer in a sync step—rclone or your S3 CLI of choice—right after the files are written. Keep credentials in environment variables or Docker secrets, not in the repo.
Equally important: test restore once. Stop the stack, start only MariaDB and a throwaway container, import db.sql.gz into a fresh database, and untar wp-content into a temp directory. You’re not aiming for a full cutover here; you just want the confidence that the files are valid and the dump opens.
If you want a bigger picture on recovery planning, I wrote about my calm, no‑drama approach to a DR plan with real backup tests. It pairs nicely with an automatic backup like this.
A friendly, low‑risk update flow
Updates are where many WordPress deployments wobble. There are two kinds to think about: container updates (images for Nginx, WordPress FPM, MariaDB, Redis) and in‑app updates (plugins, themes, WordPress core from inside the dashboard). I like to keep container updates under version control and plugin/theme updates inside WordPress, because that’s where owners expect them to live.
Here’s a safe, repeatable rhythm I use:
First, pull and roll the containers. On a maintenance window—or honestly, any calm afternoon—run:
docker compose pull nginx wordpress db redis
docker compose up -d
Because we’re not storing irreplaceable data in the stateless services, this is painless. The tricky one is MariaDB; I only upgrade DB versions after reading release notes and planning a short maintenance window. For minor updates within the same major series, it’s usually smooth.
Second, lock in application updates with WP‑CLI. I try to use WP‑CLI from inside the WordPress container so paths and PHP match exactly. This command checks what’s available:
docker compose exec wordpress wp core check-update
And these will nudge everything forward:
# Minor core updates only (safe):
docker compose exec wordpress wp core update --minor
# Plugin updates:
docker compose exec wordpress wp plugin update --all
# Theme updates:
docker compose exec wordpress wp theme update --all
Before doing any of the above, I like to take an on‑demand backup with our backup container, just in case today is the day a plugin decides to be dramatic:
docker compose run --rm backup /bin/sh /backup/run.sh
If you want even more safety, do a quick blue/green dance using Compose’s project flag. Spin a staging copy on a different project name (and a different host port), test there, then promote:
# Clone your repo into a parallel directory and switch to it
# then run a short-lived staging project
# In the staging directory:
docker compose -p wpstage up -d
# Point your hosts file to test, or visit http://<server-ip>:<staging-port>
# When you're happy, destroy staging and update prod images
docker compose -p wpstage down
# Back in prod directory:
docker compose pull
docker compose up -d
It’s nothing fancy, but it gives you room to breathe. And when something goes sideways, you’re a single compose down/up away from a clean state.
What can go wrong (and how I recover without drama)
One of my clients once called me from the road: “We updated a plugin and now every page is white.” Classic. We’d done our homework though, so it was a five‑minute fix. First we took an on‑demand backup (never hurts). Then we disabled the plugin from WP‑CLI:
docker compose exec wordpress wp plugin deactivate the-bad-plugin
Pages came back, we rolled the plugin back later. The point isn’t magic; it’s having a predictable toolbox.
If a database update misbehaves, I’ll restore from the last known‑good dump. The flow looks like this:
# Stop the stack
docker compose down
# Start only DB in the background
docker compose up -d db
# Create a temporary import container
cat backups/<STAMP>/db.sql.gz | gunzip |
docker compose exec -T db mysql -u wpuser -p"supersecret" wpdb
# Bring the rest back up
docker compose up -d
If uploads or plugin files were damaged—and this is rare—I’d replace wp-content entirely from the wp-content.tgz archive. It’s a blunt instrument, but it works.
The other failure I see is “It worked yesterday, but now we get random 502s.” That’s usually a container died and restarted. Healthchecks help here, but logs tell you the truth. I try Nginx first:
docker compose logs --tail=200 nginx
If it’s clean, PHP‑FPM next:
docker compose logs --tail=200 wordpress
Nine times out of ten, the fix is plain—bad plugin update, missing PHP extension, or memory limit too low. Bump PHP memory to 256M or 512M, recycle the container, and life goes on.
Security and stability touches that matter
I won’t turn this into a hardening manifesto, but a few gentle touches go a long way. First, don’t bake secrets into your docker-compose.yml. Use environment files or Docker secrets, especially for DB passwords and offsite backup credentials. Second, keep Nginx and WordPress images moving; stale images often mean stale security patches. Third, permissions: let the container write to wp-content and nowhere else. Mount everything else read‑only when possible. Fourth, rate limit login endpoints at Nginx if your site gets hammered; it’s a calm way to push back on brute force without exhausting PHP.
For teams that like stronger walls around their admin panels, client certificates (mTLS) on a separate admin subdomain can be a nice upgrade, but that’s a separate pattern. The core idea is the same: keep simple things simple, and make secure things boring so everyone trusts them.
A tiny performance sprinkle, only when it pays
WordPress runs surprisingly well on this stack without extra tricks. Redis object caching gives you a nice lift. Nginx handles static assets efficiently. FPM can breathe with a reasonable pm.max_children. I don’t rush into full page caching or CDN setups until traffic warrants it. The day you see high anonymous traffic and the DB getting too chatty, you can introduce microcaching at Nginx, or use a CDN for images and static files. But don’t let perf tuning distract you from backups and updates—that’s the part that bites when neglected.
WP‑CLI quick wins you’ll use all the time
When you get comfortable with WP‑CLI inside the container, it becomes your Swiss Army knife.
# Clear Redis object cache if needed
docker compose exec wordpress wp redis flush
# Create a new admin user quickly
docker compose exec wordpress wp user create jane [email protected] --role=administrator --user_pass='TempPass123!'
# Search and replace after a domain change (test with --dry-run first)
docker compose exec wordpress wp search-replace 'http://old.example.com' 'https://new.example.com' --all-tables
Little commands like these shrink big problems. And they make “I’ll just fix it” a five‑minute reality instead of a night of guessing.
Putting it all together (and where to go next)
We’ve covered a lot, but the flow is wonderfully straightforward when you live with it for a week. Docker Compose lays out a clean map of your world: Nginx in front, PHP‑FPM serving WordPress, MariaDB holding the state, Redis speeding up the chatter. Persistent volumes protect your uploads and database. A small cron container takes reliable backups. Updates happen with a single pull and a few WP‑CLI commands. And when you need to roll back, you can—quickly, calmly, without drama.
If you want to explore more Docker‑centric WordPress patterns and the quiet little habits that keep a VPS happy, I’ve written a broader playbook that complements this stack nicely. It leans into the same ideas—predictability, boring automation, and repeatable updates—and shows how to grow the setup without tripping over it.
Step‑by‑step: your first deployment checklist
Here’s the flow I give friends when they’re spinning up their first Compose‑based WordPress. It’s not a formal checklist—more like a friendly trombone of steps you can hum through.
First, clone your repo to the server, create the directories (nginx, php, backup), and drop the configs we used above into place. Second, bring up the stack with docker compose up -d and watch logs until the site responds. Third, visit the WordPress installer, complete the setup, and immediately install and activate the Redis Object Cache plugin. Fourth, run your first backup manually to prime the directory and be sure it succeeds. Fifth, schedule your offsite sync and test restore once, even if it’s just on a throwaway VM. Sixth, set a monthly reminder to run updates, or better yet, do them on a quiet Friday morning while your coffee is still hot.
When you hit a speed bump—and you will at some point—remember the mental model: database and uploads are the gold; everything else is replaceable. Start with logs, use WP‑CLI for surgical fixes, and keep your changes small. You’ll develop a feel for this that no guide can give you, and that’s when it really becomes fun.
Wrap‑up: the calm, repeatable way to run WordPress
There’s a moment I love when someone logs into their site after we switch to this stack. They click around and say, “Huh—it just feels solid.” That’s Docker Compose doing its quiet work. Separate services, clear boundaries, persistent volumes where it matters, and little scripts that run like clockwork. Pair that with Redis for zip, a cron container for backups, and a friendly update routine, and WordPress stops being a moving target and starts being a stable part of your week.
My advice if you’re just getting into this: start small and boring. Use the official images. Keep configuration tidy and versioned. Test your backups once. Learn two or three WP‑CLI commands by heart. And don’t be afraid to nudge things forward in little steps rather than big leaps. You’ll keep your uptime, your sleep, and your sanity.
Hope this was helpful. If you got stuck on a specific piece, take a breath, skim the logs, and keep the mental model close by. You’ve got this—and your site will feel better for it.
