
WordPress with Docker Compose: Production-Ready Setup
The classic way to host WordPress is to install Apache, PHP and MySQL straight onto the server. It works fine until the day you need a different PHP version, move to a new server, or try to remember which config file you edited eight months ago. Docker Compose fixes that: the whole site becomes one readable file plus a few folders you can back up, copy and rebuild anywhere. In this tutorial we build a WordPress Docker Compose stack we would be comfortable running for a real business. It has WordPress, MariaDB and Redis in containers, Caddy in front with automatic HTTPS, upload limits big enough for a real media library, persistent volumes, WP-CLI for the boring jobs, and a backup routine that you actually test instead of just hoping it works.
Every component here is open source and comes from its official project: WordPress , MariaDB , Redis , Caddy and Docker . We explain every concept the first time it appears, so you can follow along even if this is your first container project.
What our WordPress Docker Compose stack looks like
Let’s start with a few terms we will use constantly:
- Container: an isolated process that runs on your server with its own filesystem, libraries and settings. It does not carry a full operating system like a virtual machine does, so it starts in a second and uses very little memory.
- Image: the read-only template a container is created from, for example
wordpress:php8.3-apache. The part after the colon is the tag, which selects a version. - Docker Compose: a tool that reads one YAML file (
compose.yaml) describing several containers and how they connect. Then it starts, stops or updates all of them with one command. - Volume: storage managed by Docker that survives when a container is deleted or recreated. Your database and uploads live in volumes, never inside the container itself.
- Port: a numbered “door” on the server. Web traffic uses port 80 (HTTP) and 443 (HTTPS). Publishing a port means making a container reachable from the internet.
- Reverse proxy: a server that receives every visitor request first, handles HTTPS, and forwards the request to the right container behind it.
- Docker network: a private virtual network between containers. Containers on the same network reach each other by service name, for example
dborredis.
Here is the architecture we are building:
The stack has five services:
- caddy: the reverse proxy. It obtains and renews free Let’s Encrypt certificates automatically and forwards traffic to WordPress.
- wordpress: the official image with Apache and PHP. It is never exposed directly to the internet.
- db: MariaDB, the database. It sits on an internal network with no route to the internet.
- redis: an in-memory object cache. It stores the results of repeated database queries, so WordPress sends fewer queries to MariaDB.
- wpcli: the WordPress command line tool. It starts only when we call it and shares the same files and database settings as WordPress.
Why Caddy as the reverse proxy
Nginx with Certbot works perfectly well, but it means two tools, a renewal timer and a config file full of TLS settings. Caddy handles certificates by itself, uses sensible HTTPS defaults, and needs about ten lines of configuration for our site. It also has no request body size limit by default, which removes a classic cause of failed uploads (the dreaded “413 Request Entity Too Large” from Nginx).
Which image tags we use, and why not :latest
A tag like :latest changes under your feet. One day docker compose pull gives you a new major database version you did not plan for. We pin each image to a major version line, so we still get security fixes:
wordpress:php8.3-apacheandwordpress:cli-php8.3: the important detail is that WordPress core lives in the volume and updates itself (from the dashboard or WP-CLI). The image tag mainly decides the PHP version and Apache. Aphp8.4variant also exists. Whatever you choose, keep the WordPress and CLI tags on the same PHP version.mariadb:11.8: the current long-term support (LTS) release line.redis:8-alpineandcaddy:2-alpine: current major versions on the small Alpine base.
Before you start, check the tags pages on Docker Hub and the release notes of each project for newer versions. Tags evolve, and the newest supported line is the one you want.
What you need
- A VPS with Debian 13 or Ubuntu 24.04, preferably KVM. 2 GB of RAM is comfortable. 1 GB works for a small site if you add swap. Plan for at least 20 GB of disk.
- A user with sudo rights and SSH access. We do not work as root.
- A domain name (we use
blog.example.comin the examples) with an A record pointing to the VPS IPv4 address. Add an AAAA record too if your VPS has IPv6. - Ports 80 and 443 free on the server, so no Apache or Nginx already installed on the host.
- About 45 minutes and a terminal.
How much memory does all of this actually use? Less than most people expect:
The procedure at a glance
We will go through eight steps. The diagram below is your map, and every step has its own heading with the same number:
Step 1: Point your domain and update the server
First, go to your DNS provider and create an A record for blog.example.com with the IPv4 address of your VPS. DNS changes can take a few minutes to propagate. Caddy needs this record to work before we start the stack, because Let’s Encrypt checks that the domain really points to your server. You can check it from the VPS:
getent hosts blog.example.com
The output should show your VPS IP. If you use Cloudflare, set the record to “DNS only” (grey cloud) during the setup. You can turn the proxy on later, with SSL mode set to “Full (strict)”.
Next, bring the system up to date:
sudo apt update
sudo apt full-upgrade -y
Now enable automatic security updates. On Ubuntu 24.04, unattended-upgrades is installed and active by default. On Debian 13, install and enable it yourself:
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
Should you reboot? On Ubuntu, the system creates a marker file when a reboot is needed:
[ -f /var/run/reboot-required ] && echo "Reboot needed"
Debian does not always create that file, so the rule there is simple: reboot if the upgrade installed a new linux-image package (you will see it in the apt output). Use sudo reboot and log back in after a minute.
Finally, set up a basic firewall with ufw for the host itself. Ubuntu ships with ufw already installed, but Debian does not:
sudo apt install -y ufw # needed on Debian, already present on Ubuntu
sudo ufw allow OpenSSH # if SSH runs on a custom port, allow that port instead
sudo ufw allow 80/tcp
sudo ufw allow 443
sudo ufw enable
sudo ufw status
An important detail that confuses many people: Docker writes its own firewall rules and bypasses ufw for published container ports. ufw protects services running directly on the host, like SSH. It does not filter ports published by Docker. That is why, in our stack, only Caddy publishes ports, and the database and Redis are never published at all.
Step 2: Install Docker Engine and Docker Compose
We install Docker from Docker’s official repository, not the older package from the distribution, so we get the current Compose v2 plugin. These commands work on both Debian and Ubuntu, because they read the distribution name from /etc/os-release:
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
. /etc/os-release
sudo curl -fsSL "https://download.docker.com/linux/${ID}/gpg" -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/${ID}
Suites: ${UBUNTU_CODENAME:-$VERSION_CODENAME}
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
The docker.sources file uses the modern deb822 format that apt now prefers. Next, allow your user to run Docker without sudo, then log out and back in so the group change applies:
sudo usermod -aG docker "$USER"
exit
After you reconnect, check that everything works:
docker run --rm hello-world
docker compose version
You should see “Hello from Docker!” and a Compose version starting with v2. Keep in mind that membership in the docker group is effectively root access, so only add users you trust completely.
Note for LXC users: Docker inside an unprivileged LXC container needs nesting (and usually keyctl) enabled on the container. Without them, the Docker daemon fails to start or containers fail with permission errors. On a KVM VPS this does not apply. If you are on LXC with us and Docker will not start, ask support to enable nesting.
Step 3: Create the project folder and the .env secrets
All files for the site live in one folder. That makes the project easy to back up and to move:
mkdir -p ~/wordpress/php
cd ~/wordpress
Passwords do not belong inside compose.yaml. We put them in a .env file, which Docker Compose reads automatically from the project folder. The command below generates random hex passwords. Hex characters (0-9, a-f) are safe in YAML, shell and PHP, so there are no escaping surprises. Replace the domain and the email with your own:
cat > .env <<EOF
DOMAIN=blog.example.com
ACME_EMAIL=admin@example.com
DB_NAME=wordpress
DB_USER=wordpress
DB_PASSWORD=$(openssl rand -hex 24)
DB_ROOT_PASSWORD=$(openssl rand -hex 24)
REDIS_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 .env
cat .env
chmod 600 makes the file readable only by your user. ACME_EMAIL is where Let’s Encrypt sends notices about your certificates.
One rule worth memorizing now: MariaDB reads DB_NAME, DB_USER and the passwords only the first time it initializes its volume. If you change the password in .env later, the database keeps the old one, and WordPress will fail with “Error establishing a database connection”. To change a password later, change it inside MariaDB first, then update .env.
Step 4: Set PHP upload limits and write the Caddyfile
The official WordPress image uses the PHP default upload limit of 2 MB, which is too small for anything beyond a few photos. We fix this with a small PHP config file that we mount into the container:
cat > php/uploads.ini <<'EOF'
file_uploads = On
upload_max_filesize = 64M
post_max_size = 64M
memory_limit = 256M
max_execution_time = 120
max_input_vars = 3000
EOF
Here is what each line does:
upload_max_filesize: the largest single file you can upload.post_max_size: the largest complete form submission. It must be equal to or larger than the upload limit.memory_limit: how much RAM a single PHP request may use. Page builders and image processing need more than the default.max_execution_time: how long a request may run before PHP stops it, which helps with large imports.max_input_vars: raises the limit for big menus and option pages that would otherwise save only partially.
Now the Caddyfile, Caddy’s configuration file:
cat > Caddyfile <<'EOF'
{
email {$ACME_EMAIL}
}
{$DOMAIN} {
encode zstd gzip
# XML-RPC is a common brute-force target; remove these two lines if you use Jetpack or the mobile app
@xmlrpc path /xmlrpc.php
respond @xmlrpc 403
header {
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}
reverse_proxy wordpress:80
}
# Uncomment if you also have a DNS record for www:
# www.{$DOMAIN} {
# redir https://{$DOMAIN}{uri} permanent
# }
EOF
{$DOMAIN} and {$ACME_EMAIL} are environment variables that we will pass to the Caddy container. encode compresses responses. reverse_proxy wordpress:80 sends everything to the WordPress container, which Caddy finds by name on the Docker network. Caddy automatically adds the X-Forwarded-Proto: https header. The official WordPress image reads that header, so WordPress knows the visitor is on HTTPS and you do not get redirect loops. The image is also configured to trust X-Forwarded-For from private networks, so your logs and security plugins see real visitor IPs, not Caddy’s address.
Step 5: Write the WordPress Docker Compose file
This is the heart of the setup. Create compose.yaml:
name: wordpress
x-wp-env: &wp-env
WORDPRESS_DB_HOST: db
WORDPRESS_DB_NAME: ${DB_NAME}
WORDPRESS_DB_USER: ${DB_USER}
WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
WORDPRESS_TABLE_PREFIX: wp_
WORDPRESS_CONFIG_EXTRA: |
define('WP_REDIS_HOST', 'redis');
define('WP_REDIS_PORT', 6379);
define('WP_REDIS_PASSWORD', '${REDIS_PASSWORD}');
define('WP_CACHE_KEY_SALT', '${DOMAIN}:');
define('DISABLE_WP_CRON', true);
define('DISALLOW_FILE_EDIT', true);
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
DOMAIN: ${DOMAIN}
ACME_EMAIL: ${ACME_EMAIL}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- frontend
depends_on:
- wordpress
wordpress:
image: wordpress:php8.3-apache
restart: unless-stopped
environment: *wp-env
volumes:
- wp_data:/var/www/html
- ./php/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro
networks:
- frontend
- backend
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: mariadb:11.8
restart: unless-stopped
environment:
MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD: ${DB_PASSWORD}
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_AUTO_UPGRADE: "1"
volumes:
- db_data:/var/lib/mysql
networks:
- backend
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
redis:
image: redis:8-alpine
restart: unless-stopped
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}", "--maxmemory", "128mb", "--maxmemory-policy", "allkeys-lru", "--save", "", "--appendonly", "no"]
networks:
- backend
wpcli:
image: wordpress:cli-php8.3
profiles: ["tools"]
user: "33:33"
environment:
<<: *wp-env
WP_CLI_CACHE_DIR: /tmp/wp-cli-cache
volumes:
- wp_data:/var/www/html
networks:
- frontend
- backend
depends_on:
db:
condition: service_healthy
networks:
frontend:
backend:
internal: true
volumes:
wp_data:
db_data:
caddy_data:
caddy_config:
Let’s walk through the parts that matter:
name: wordpressfixes the project name. Docker prefixes volume names with it, so our WordPress files end up in a volume called exactlywordpress_wp_data. The backup script relies on that name.x-wp-env: &wp-envis a reusable block of settings (a YAML “anchor”). WordPress and WP-CLI must see identical database settings, so we define them once and reuse them with*wp-envand<<: *wp-env.WORDPRESS_CONFIG_EXTRAadds PHP lines towp-config.phpat runtime. They configure the Redis connection, give each site its own cache prefix, turn off the built-in “visitor-triggered” cron (we replace it with a real one in step 7), and disable the theme and plugin file editor in the dashboard.- Only Caddy has a
ports:section. WordPress, MariaDB and Redis are reachable only from inside Docker networks. Port 443/udp enables HTTP/3. - The
backendnetwork isinternal: true, so MariaDB and Redis have no internet access at all. WordPress joins both networks, so it can still download updates and plugins. - The healthcheck uses the script shipped with the MariaDB image.
condition: service_healthymakes WordPress wait until the database really accepts connections, which avoids errors on the first start. MARIADB_AUTO_UPGRADEruns the internal upgrade procedure automatically when you move to a newer MariaDB minor version.- Redis runs as a pure cache: it uses at most 128 MB, evicts the least recently used keys when full, and writes nothing to disk. Losing the cache on a restart is harmless, because it simply refills.
- wpcli is in the
toolsprofile, sodocker compose updoes not start it. It runs as user33:33(www-datain the WordPress image), so files it creates have the same owner as the files Apache writes.
Before starting anything, ask Compose to validate the file:
docker compose config --quiet && echo "compose.yaml is valid"
If there is a typo or an indentation problem, Compose tells you the line. YAML is strict about indentation, so always use spaces, never tabs.
Step 6: Start the stack and install WordPress
Download the images and start everything in the background (-d means detached):
docker compose pull
docker compose up -d
docker compose ps
After 20-40 seconds, docker compose ps should list caddy, wordpress, db and redis, with the database marked healthy. Now watch Caddy obtain the certificate:
docker compose logs -f caddy
Look for a line containing certificate obtained successfully, then press Ctrl+C to stop following the logs (the containers keep running). Test from the server:
curl -I https://blog.example.com
A fresh WordPress answers with HTTP/2 302 and a location: header pointing to /wp-admin/install.php. That means HTTPS, Caddy and WordPress are all working. Let’s also confirm that our upload limits are active:
docker compose exec wordpress php -r 'echo ini_get("upload_max_filesize"), " / ", ini_get("post_max_size"), PHP_EOL;'
The expected output is 64M / 64M.
Now finish the installation right away, because until then anyone who finds the URL could complete the setup wizard. You have two options:
- In the browser: open
https://blog.example.com, choose a language, and set the site title, an admin username (not “admin”) and a strong password. - With WP-CLI, without typing the password into your shell history:
docker compose run --rm wpcli wp core install \
--url="https://blog.example.com" \
--title="My Site" \
--admin_user="siteadmin" \
--admin_email="admin@example.com" \
--skip-email \
--prompt=admin_password
WP-CLI asks for the password interactively and answers with Success: WordPress installed successfully.
Step 7: Use WP-CLI for the Redis cache and a real cron
WP-CLI is the official command line interface for WordPress. It can install plugins, run updates, manage users and change settings, all without clicking through the dashboard, and it is perfect for scripts. Our wpcli service starts a short-lived container, runs one command and then removes itself (--rm).
Enable the Redis object cache
docker compose run --rm wpcli wp plugin install redis-cache --activate
docker compose run --rm wpcli wp redis enable
docker compose run --rm wpcli wp redis status
The first command installs the Redis Object Cache plugin. The second copies its object-cache.php drop-in into wp-content. The status output should include Status: Connected. From now on, repeated database lookups (options, menus, post metadata) are served from memory. On busy sites and in the admin area, this noticeably reduces page generation time.
A shortcut so you can just type “wp”
Typing docker compose run --rm wpcli wp every time gets old quickly. Add an alias:
echo "alias wp='docker compose -f \$HOME/wordpress/compose.yaml run --rm wpcli wp'" >> ~/.bashrc
source ~/.bashrc
wp core version
The alias works from any folder, because -f tells Compose where the project lives. A few commands you will use often:
wp plugin list # installed plugins and available updates
wp core update # update WordPress core
wp plugin update --all # update all plugins
wp theme update --all # update all themes
wp user list # list accounts
wp option get home # check the site URL
wp search-replace 'http://old.example.com' 'https://blog.example.com' --dry-run
search-replace with --dry-run shows what would change, for example after a migration. Run it again without --dry-run to apply the changes.
Replace WP-Cron with a real cron job
By default, WordPress runs scheduled tasks (publishing scheduled posts, plugin jobs) only when someone visits the site. On a quiet site they run late, and on a busy site the check runs on every page load. We disabled that in compose.yaml, so now we schedule it properly from the host. On Debian, install cron first if it is missing (sudo apt install -y cron). Ubuntu already has it. Then open your crontab with crontab -e and add this line, replacing deploy with your username:
*/5 * * * * cd /home/deploy/wordpress && docker compose run --rm -T wpcli wp cron event run --due-now --quiet >/dev/null 2>&1
Every five minutes, this runs all due WordPress tasks. The -T flag disables the terminal, which cron does not have.
Step 8: Back up the site and test a restore
A WordPress site consists of three things: the database (posts, settings, users), the files in wp_data (uploads, themes, plugins, wp-config.php) and our configuration (compose.yaml, Caddyfile, php/, .env). The script below saves all three. Create ~/wordpress/backup.sh:
#!/usr/bin/env bash
# backup.sh: database dump + wp_data archive + config, kept 14 days
set -euo pipefail
umask 077
cd "$(dirname "$(readlink -f "$0")")"
STAMP="$(date +%F-%H%M)"
DEST="$PWD/backups"
mkdir -p "$DEST"
# 1. Database: consistent dump, the password is read inside the container
docker compose exec -T db sh -c \
'MYSQL_PWD="$MARIADB_PASSWORD" exec mariadb-dump -u "$MARIADB_USER" --single-transaction --quick --routines --triggers "$MARIADB_DATABASE"' \
| gzip > "$DEST/db-$STAMP.sql.gz"
# 2. Files: uploads, themes, plugins, wp-config.php
docker run --rm \
-v wordpress_wp_data:/data:ro \
-v "$DEST":/backup \
debian:stable-slim \
sh -c "tar --numeric-owner -czf /backup/wp-files-$STAMP.tar.gz -C /data . && chown $(id -u):$(id -g) /backup/wp-files-$STAMP.tar.gz && chmod 600 /backup/wp-files-$STAMP.tar.gz"
# 3. Configuration (contains secrets, keep it private)
tar -czf "$DEST/config-$STAMP.tar.gz" compose.yaml Caddyfile php .env
# 4. Retention: delete archives older than 14 days
find "$DEST" -type f -name '*.gz' -mtime +14 -delete
echo "Backup $STAMP finished"
A few details worth explaining:
- The database password never appears on a command line. The single quotes mean your shell passes the text unchanged, and the variables are expanded inside the MariaDB container, where they already exist.
MYSQL_PWDhands the password tomariadb-dumpwithout putting it in the process list. --single-transactiongives a consistent snapshot without locking the site.- The files archive is created by a temporary container running as root, so we
chownit back to your user. Otherwise you would get “permission denied” when you try to copy it off the server. umask 077makes every new backup readable only by you, which matters because the backups contain your secrets.
Make it executable and run it once:
chmod +x ~/wordpress/backup.sh
~/wordpress/backup.sh
ls -lh ~/wordpress/backups
You should see three files: db-...sql.gz, wp-files-...tar.gz and config-...tar.gz. To run the backup every night at 03:30, add this to crontab -e:
30 3 * * * /home/deploy/wordpress/backup.sh >> /home/deploy/wordpress/backups/backup.log 2>&1
Copy backups off the server
A backup that lives only on the same VPS disappears together with the VPS. Send it to another machine with rsync over SSH. If that machine uses a non-standard SSH port, give it its own alias in ~/.ssh/config, so your normal SSH connections are not affected:
# ~/.ssh/config
Host backup-box
HostName backup.example.net
Port 2222
User backupuser
IdentityFile ~/.ssh/id_ed25519_backup
Then, with a passwordless key installed on that machine, schedule this in cron after the backup:
45 3 * * * rsync -az /home/deploy/wordpress/backups/ backup-box:/srv/backups/wordpress/
If the destination is not fully under your control, encrypt the archives first, or use a dedicated backup tool such as restic .
Test a restore (into an empty database)
A backup you have never restored is only a hope. The safest place to practice is a second, throwaway VPS with the same project folder. The procedure is the same on the live server. The golden rule: never import a dump on top of the live database. Stop WordPress, recreate an empty database, then import:
cd ~/wordpress
docker compose stop wordpress
# 1. Drop and recreate an empty database (root password is read inside the container)
docker compose exec -T db sh -c \
'MYSQL_PWD="$MARIADB_ROOT_PASSWORD" exec mariadb -u root -e "DROP DATABASE IF EXISTS $MARIADB_DATABASE; CREATE DATABASE $MARIADB_DATABASE CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"'
# 2. Import the dump (use your own file name)
gunzip -c backups/db-2026-11-10-0330.sql.gz | docker compose exec -T db sh -c \
'MYSQL_PWD="$MARIADB_PASSWORD" exec mariadb -u "$MARIADB_USER" "$MARIADB_DATABASE"'
# 3. Replace the files in the wp_data volume
docker run --rm \
-v wordpress_wp_data:/data \
-v "$PWD/backups":/backup:ro \
debian:stable-slim \
sh -c "find /data -mindepth 1 -delete && tar --numeric-owner -xzf /backup/wp-files-2026-11-10-0330.tar.gz -C /data"
# 4. Start WordPress and clear the object cache
docker compose start wordpress
docker compose run --rm wpcli wp cache flush
The WordPress user keeps its privileges, because MariaDB does not remove database-level grants when a database is dropped. Open the site, log in and check a few posts and images. If everything looks right, your backups are real.
Keeping the stack updated
There are two layers to update, and it helps to keep them separate in your head:
cd ~/wordpress
./backup.sh # always back up first
docker compose pull # new images: PHP, Apache, MariaDB, Redis, Caddy patches
docker compose up -d # recreates only the containers whose image changed
docker image prune -f # removes old, unused images
wp core update && wp plugin update --all && wp theme update --all
docker compose pull updates the software around WordPress. wp core update updates WordPress itself, because the core lives in the volume. To move to a newer PHP version, change both wordpress:php8.3-apache and wordpress:cli-php8.3 to the new version, run docker compose up -d, and test your theme and plugins. To move to a new MariaDB LTS line, take a backup, change the tag, and let MARIADB_AUTO_UPGRADE do the rest. Do not skip intermediate major versions without reading the MariaDB upgrade notes.
Troubleshooting and common mistakes
Caddy cannot obtain a certificate
Run docker compose logs caddy. The usual causes are: the A record does not point to this VPS yet (check with getent hosts), port 80 is blocked by an external firewall, or a Cloudflare proxy is active during the first issuance. Fix the cause and run docker compose restart caddy. Let’s Encrypt limits repeated failures, so do not restart it in a loop.
“Error establishing a database connection”
Check docker compose ps: is db healthy? If it is, the password in .env probably differs from the one stored in the database. This happens when .env was edited after the first start, or when an old db_data volume is still around. On a fresh install with no content, docker compose down -v removes the volumes so you can start over. Never run this on a site with real data.
Redirect loops or mixed content warnings
These usually come from an extra proxy (for example Cloudflare with “Flexible” SSL) that talks plain HTTP to Caddy. Use “Full (strict)”. Also check that wp option get home and wp option get siteurl both start with https://.
“The uploaded file exceeds the upload_max_filesize directive”
Check the PHP values with the php -r command from step 6. If they still show 2M, the php/uploads.ini path in compose.yaml is wrong, or you did not run docker compose up -d after editing it. If you put another proxy such as Nginx in front, raise its client_max_body_size too.
WP-CLI says “permission denied” or “This does not seem to be a WordPress installation”
The first message means WP-CLI ran without user: "33:33". The second means you ran it before WordPress had copied its files into the volume. Start the stack first with docker compose up -d, wait a few seconds, then try again.
“port is already allocated” for 80 or 443
Something on the host already uses the port, often an Apache or Nginx installed earlier. Find it with sudo ss -tlnp | grep -E ':80 |:443 ', then stop and disable that service.
I edited .env or compose.yaml and nothing changed
Containers keep their old settings until they are recreated. Run docker compose up -d, and Compose recreates only what changed. (Remember the exception: MariaDB passwords stored in an existing volume.)
Security notes
- Docker bypasses ufw. Never add
ports:todborredis. If you ever need database access from your laptop, use an SSH tunnel instead of publishing port 3306. - The
dockergroup equals root. Anyone in it can control the whole server. Keep that group short. - Protect your secrets.
.envstays atchmod 600, never goes into a public Git repository, and your backups contain it too. Encrypt anything that leaves the server. - Harden WordPress itself. Use a unique admin username, a long password and a two-factor authentication plugin.
DISALLOW_FILE_EDITis already set, and XML-RPC is blocked at the proxy. - Update regularly. Most WordPress compromises come from outdated plugins, not from the server. A weekly
wp plugin update --all(after a backup) prevents a lot of trouble. - HSTS later. Once you are sure HTTPS works for every subdomain you use, you can add
Strict-Transport-Security "max-age=31536000"to the Caddyheaderblock. Do not enable it on day one, because it is hard to undo. - For a further hardening step, Docker secrets (
WORDPRESS_DB_PASSWORD_FILEandMARIADB_PASSWORD_FILE) can replace environment variables.
So, is Docker Compose the right way to run WordPress?
For most small and medium sites, yes. You get a reproducible setup described in one file, HTTPS that renews itself, a database that is not exposed to the internet, a real object cache, sane upload limits and a backup you have actually restored. Moving to a bigger server becomes “copy the folder, restore the backup, update DNS”. The price is that you are now the sysadmin: someone has to apply updates, watch disk space and check that the nightly backups still arrive.
You can start this stack on a VPS from our Romanian data center in a few minutes. If you would rather focus on content and leave updates, monitoring and backups to someone else, our server administration team can build and look after the whole setup for you.
FAQ
Can I host several WordPress sites on one VPS with this setup?
Yes. Create one folder per site, each with its own compose.yaml (different name: values), database and volumes, and use a single Caddy instance in front of all of them, connected to each site’s frontend network. Only one container can publish ports 80 and 443 on a server.
Why not just use the :latest tags?
Because :latest can jump to a new major version during a routine docker compose pull, which matters especially for the database. Pinned major or LTS tags still receive security patches, while upgrades stay a deliberate decision.
Does this work in an LXC container?
Yes, if nesting is enabled for the container. Unprivileged LXC containers without nesting cannot run the Docker daemon properly. A KVM VPS has no such limitation.
Should I use Nginx instead of Caddy?
You can. Nginx with Certbot is a solid choice. Just remember to set client_max_body_size to match your PHP limits and to forward the X-Forwarded-Proto header. We chose Caddy because automatic HTTPS and short configuration mean fewer moving parts.
Where are my uploads physically stored?
In the Docker volume wordpress_wp_data, under /var/lib/docker/volumes/ on the host. Do not edit files there by hand. Use WP-CLI, the dashboard, or the backup and restore commands from step 8.