Recipe: WordPress and MySQL with secrets

Two services, secrets WordPress can read, health checks, and a tested backup.

Intermediate14 min · lesson 22 of 24
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (4 files, 2 KB): r-wordpress.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-hard/r-wordpress.tar.gz && tar -xzf r-wordpress.tar.gz, which creates ~/lab/r-wordpress/. SHA-256: 0b2199906f8f5add5845546bca334e05c111262e6d81d1be23f1350a4e8f7679

This recipe builds the classic two-service WordPress stack, WordPress on PHP 8.4 and Apache with MySQL 8.4, with file secrets, health checks, and a backup that has been restored at least once. On the way it reproduces the failure this stack most often shows on a Linux host: MySQL healthy, the password right, the secret file mounted where the image expects it, and every page answering "Error establishing a database connection" because of one chmod 600 that most guides recommend. It runs on the main lab VM, secopslog-docker, in ~/lab/r-wordpress with the lesson files.

The Compose file

compose.yaml
name: lab-wp
services:
db:
image: mysql:8.4@sha256:6ea90827b1100f8f2ae306a539f86d2c264a26ed435a2a9f75551dd5c3aeb242
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wp
MYSQL_PASSWORD_FILE: /run/secrets/db_password
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
MYSQL_ROOT_HOST: localhost
secrets: [db_password, db_root_password]
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
interval: 5s
timeout: 3s
retries: 10
start_period: 60s
# time for InnoDB to flush and shut down cleanly before Docker sends SIGKILL (default 10s)
stop_grace_period: 1m
restart: unless-stopped
wordpress:
image: wordpress:php8.4-apache@sha256:2007075ffdfcdbf6cee457615658f2f02ed34722f0a599c2f80c43fbd0be31b3
ports:
- "127.0.0.1:8080:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wp
WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
volumes:
- wp_html:/var/www/html
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1/"]
interval: 10s
timeout: 5s
retries: 3
start_period: 20s
restart: unless-stopped
volumes:
db_data:
wp_html:
secrets:
db_password:
file: ./secrets/db_password.txt
db_root_password:
file: ./secrets/db_root_password.txt

The database settings follow "Recipe: PostgreSQL, MySQL and MongoDB", which explains them in depth: a named volume for /var/lib/mysql, passwords read from files through the _FILE variables, MYSQL_ROOT_HOST: localhost so the image does not create a root@'%' account, and a mysqladmin ping health check against 127.0.0.1, which cannot pass while the image is still running its first-start initialisation. The database publishes no port. stop_grace_period: 1m gives MySQL a minute to flush InnoDB and shut down cleanly; with Docker's default of ten seconds a busy server can be killed mid-shutdown and run crash recovery on the next start. depends_on with condition: service_healthy holds WordPress back until MySQL accepts connections ("Multi-container apps with Compose" in Docker for beginners covers the conditions). WordPress finds the database as db through Docker's embedded DNS server on the project network.

WordPress gets its own health check. curl -fsS http://127.0.0.1/ fails on any HTTP status of 400 or above, and WordPress returns 500 when it cannot reach its database, so this check fails exactly when the site is broken. Before installation the same URL returns a 302 redirect to the installer, which counts as healthy. The image ships curl, which is not true of every image; check before you write a probe. The site is published on the VM's loopback address only. In production, put it behind a TLS proxy as in "Recipe: NGINX as a reverse proxy".

Secrets the usual way, and the 500

Generate two random passwords and lock the files down, as most guides say:

ubuntu@secopslog-docker:~/lab/r-wordpress · Docker 29.8.2
$ mkdir secrets openssl rand -base64 24 > secrets/db_password.txt openssl rand -base64 24 > secrets/db_root_password.txt chmod 600 secrets/*.txt ls -l secrets
total 8 -rw------- 1 ubuntu ubuntu 33 Oct 8 04:08 db_password.txt -rw------- 1 ubuntu ubuntu 33 Oct 8 04:08 db_root_password.txt
ubuntu@secopslog-docker:~/lab/r-wordpress · Docker 29.8.2
$ docker compose up -d --wait
... container lab-wp-wordpress-1 is unhealthy
$ docker compose ps --format 'table {{.Name}}\t{{.Status}}'
NAME STATUS lab-wp-db-1 Up 56 seconds (healthy) lab-wp-wordpress-1 Up 40 seconds (unhealthy)
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/ curl -s http://localhost:8080/ | grep -o 'Error establishing a database connection'
500 Error establishing a database connection

MySQL is healthy, so it read its secrets. WordPress started, and its health check turned it unhealthy because the site returns 500. The difference is who reads the file, and when:

ubuntu@secopslog-docker:~/lab/r-wordpress · Docker 29.8.2
$ docker compose exec -u www-data wordpress sh -c 'id; ls -ln /run/secrets; cat /run/secrets/db_password >/dev/null'
uid=33(www-data) gid=33(www-data) groups=33(www-data) total 4 -rw------- 1 1000 1000 33 Oct 7 22:38 db_password cat: /run/secrets/db_password: Permission denied

Compose without Swarm gives each secret to the container as a read-only bind mount of the host file, with the host's owner (UID 1000, ubuntu on the VM) and mode. The MySQL entrypoint reads its _FILE variables as root, before it drops to the mysql user, and root reads anything. WordPress does not read the password at startup at all: its wp-config.php reads WORDPRESS_DB_PASSWORD_FILE on every request, inside PHP, as www-data (UID 33). That user gets "Permission denied", so WordPress has no password to log in with.

Compose has a long syntax for secrets with uid, gid and mode, and it looks like the fix. The lesson files include an override that sets them:

secrets-long-syntax.yaml
# An override that tries to fix ownership with the long secret syntax.
# Outside Swarm mode, Compose ignores uid, gid and mode (and says so).
services:
wordpress:
secrets:
- source: db_password
uid: "33"
gid: "33"
mode: 0400
ubuntu@secopslog-docker:~/lab/r-wordpress · Docker 29.8.2
$ docker compose -f compose.yaml -f secrets-long-syntax.yaml config --quiet
time="2026-10-08T04:09:20+05:30" level=warning msg="service \"wordpress\": secrets.db_password.gid: gid is not supported outside Swarm mode and will be ignored" time="2026-10-08T04:09:20+05:30" level=warning msg="service \"wordpress\": secrets.db_password.mode: mode is not supported outside Swarm mode and will be ignored" time="2026-10-08T04:09:20+05:30" level=warning msg="service \"wordpress\": secrets.db_password.uid: uid is not supported outside Swarm mode and will be ignored"

Compose 5.6.0 says it plainly: those fields are Swarm features and are ignored here. What works outside Swarm is making the file readable to the container's user and protecting it on the host with the directory:

ubuntu@secopslog-docker:~/lab/r-wordpress · Docker 29.8.2
$ chmod 700 secrets chmod 644 secrets/*.txt ls -ld secrets secrets/*.txt
drwx------ 2 ubuntu ubuntu 4096 Oct 8 04:08 secrets -rw-r--r-- 1 ubuntu ubuntu 33 Oct 8 04:08 secrets/db_password.txt -rw-r--r-- 1 ubuntu ubuntu 33 Oct 8 04:08 secrets/db_root_password.txt
$ docker compose up -d --wait --force-recreate wordpress
... Container lab-wp-wordpress-1 Healthy
$ docker compose ps --format 'table {{.Name}}\t{{.Status}}\t{{.Ports}}'
NAME STATUS PORTS lab-wp-db-1 Up About a minute (healthy) 3306/tcp, 33060/tcp lab-wp-wordpress-1 Up 5 seconds (healthy) 127.0.0.1:8080->80/tcp

No other account on the host can enter a 0700 directory, so 0644 files inside it are readable only by ubuntu and root on the host, and by any user inside the containers that mount them. --force-recreate wordpress starts the container with a fresh health status; WordPress would have recovered on the next request anyway, since it reads the file each time. The ps output shows the database's 3306/tcp, 33060/tcp as exposed ports without a host binding: reachable from the project network only.

The file secret keeps the passwords out of the Compose file and out of docker inspect, which lists only the _FILE variables. It does not keep them out of every process: the MySQL entrypoint exports the values it read, and they stay in the server process's environment, readable through /proc/1/environ inside the container. "Recipe: PostgreSQL, MySQL and MongoDB" shows it, and "Runtime secrets, done right" (Advanced container security) discusses what that means for your threat model. The same lesson explains why changing a secret file later does not change the database password: the image applies MYSQL_PASSWORD only when it initialises an empty volume.

A site with content

ubuntu@secopslog-docker:~/lab/r-wordpress · Docker 29.8.2
$ curl -sI http://localhost:8080/ | grep -E '^(HTTP|Location)'
HTTP/1.1 302 Found Location: http://localhost:8080/wp-admin/install.php

A fresh site redirects to its installer. The installer is a form, so it can be completed with curl; the admin password is generated and never printed. Then add a file to the uploads directory, the way a media upload would:

ubuntu@secopslog-docker:~/lab/r-wordpress · Docker 29.8.2
$ pw=$(openssl rand -base64 18) curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:8080/wp-admin/install.php?step=2' \ --data-urlencode 'weblog_title=Lab blog' --data-urlencode 'user_name=labadmin' \ --data-urlencode "admin_password=$pw" --data-urlencode "admin_password2=$pw" \ --data-urlencode 'admin_email=admin@example.com' --data-urlencode 'blog_public=0' curl -s http://localhost:8080/ | grep -o '<title>.*</title>'
200 <title>Lab blog</title>
$ docker compose exec -u www-data wordpress sh -c 'mkdir -p wp-content/uploads && echo "uploaded before the backup" > wp-content/uploads/proof.txt' curl -s http://localhost:8080/wp-content/uploads/proof.txt
uploaded before the backup

The 200 is the installer's success page and the title is the one just set. The site now has state in two places: tables in the db_data volume, and files in the wp_html volume, which holds WordPress core, themes, plugins, uploads and the generated wp-config.php. A backup needs both, taken at the same moment, or the database can reference uploads the archive does not contain.

Backup and restore

backup.sh
#!/bin/sh
# Usage: ./backup.sh DIR (run in the directory that holds compose.yaml)
# Stops WordPress so the files and the database are copied at the same moment, and starts it
# again on every exit, also when a step fails. Writes .tmp files and renames them only on success.
set -eu
umask 077
mkdir -p "$1"
dir=$(realpath "$1")
trap 'docker compose start wordpress' EXIT
docker compose stop wordpress
# Logical dump; the root password reaches mysqldump as an option file on stdin,
# never as a command-line argument.
docker compose exec -T db sh -c 'printf "[client]\nuser=root\npassword=%s\n" "$(cat /run/secrets/db_root_password)" |
mysqldump --defaults-extra-file=/dev/stdin --single-transaction --databases wordpress' > "$dir/wordpress.sql.tmp"
# File-level copy of the WordPress volume: core, themes, plugins, uploads, wp-config.php.
# The archive is written by root in the helper container; hand it back to you, mode 0600.
docker run --rm -v lab-wp_wp_html:/data:ro -v "$dir":/backup alpine:3.22 sh -c \
"tar -C /data -czf /backup/wp_html.tgz.tmp . && chown $(id -u):$(id -g) /backup/wp_html.tgz.tmp && chmod 600 /backup/wp_html.tgz.tmp"
mv "$dir/wordpress.sql.tmp" "$dir/wordpress.sql"
mv "$dir/wp_html.tgz.tmp" "$dir/wp_html.tgz"

The script stops WordPress so nothing writes during the copy, then takes a logical dump with mysqldump, which is consistent while the server runs (--single-transaction), and a tar of the files volume from a throwaway Alpine container that mounts it read-only. The root password reaches mysqldump as an option file on standard input, so it never appears as a command-line argument. The dump is written with umask 077, and the archive, created by root in the helper container, is handed back to your user with mode 0600, because the dump contains every user's password hash.

Two details make it safe to run unattended. The trap ... EXIT starts WordPress again whenever the script ends, so a failed dump or a full disk under set -eu does not turn a nightly backup into an outage. And both files are written as .tmp and renamed only after every step succeeded, so a failed run never leaves a half-written wordpress.sql that looks like a backup. Point it at a directory it cannot write to and check that the site comes back:

ubuntu@secopslog-docker:~/lab/r-wordpress · Docker 29.8.2
$ install -d -m 500 readonly ./backup.sh readonly; echo "exit=$?" docker compose ps --format "table {{.Name}}\t{{.Status}}" rmdir readonly
Container lab-wp-wordpress-1 Stopping Container lab-wp-wordpress-1 Stopped ./backup.sh: 13: cannot create /home/ubuntu/lab/r-wordpress/readonly/wordpress.sql.tmp: Permission denied Container lab-wp-db-1 Waiting Container lab-wp-db-1 Healthy Container lab-wp-wordpress-1 Starting Container lab-wp-wordpress-1 Started exit=2 NAME STATUS lab-wp-db-1 Up About a minute (healthy) lab-wp-wordpress-1 Up Less than a second (health: starting)

The dump's redirection failed, set -e ended the script with status 2, and the trap started WordPress again; it is back up and running its health check. Now the real backup:

ubuntu@secopslog-docker:~/lab/r-wordpress · Docker 29.8.2
$ ./backup.sh backup ls -l backup
... total 34852 -rw------- 1 ubuntu ubuntu 105686 Oct 8 04:09 wordpress.sql -rw------- 1 ubuntu ubuntu 35579726 Oct 8 04:09 wp_html.tgz

Now lose everything. down -v removes the containers, the network and both volumes:

ubuntu@secopslog-docker:~/lab/r-wordpress · Docker 29.8.2
$ docker compose down -v docker volume ls --filter name=lab-wp --format '{{.Name}}' | wc -l
... 0
restore.sh
#!/bin/sh
# Usage: ./restore.sh DIR (into a project with no volumes yet: after down -v, or on a new host)
set -eu
dir=$(realpath "$1")
# Refuse to overlay a live site: tar would mix old and restored files, and the dump would
# load into a database that already has data.
for v in lab-wp_db_data lab-wp_wp_html; do
if docker volume inspect "$v" >/dev/null 2>&1; then
echo "refusing: volume $v exists; restore only into a project without volumes" >&2
exit 1
fi
done
# Create the network, both volumes and the containers without starting anything.
docker compose create
docker run --rm -v lab-wp_wp_html:/data -v "$dir":/backup:ro alpine:3.22 \
tar -C /data -xzf /backup/wp_html.tgz
# MySQL initialises an empty db_data from the secrets, then the dump is loaded.
docker compose up -d --wait db
docker compose exec -T db sh -c 'mysql --defaults-extra-file=/dev/fd/3 3<<EOT
[client]
user=root
password=$(cat /run/secrets/db_root_password)
EOT' < "$dir/wordpress.sql"
docker compose up -d --wait

docker compose create makes the project's network, volumes and containers without starting them, so the files can go into the new wp_html volume before WordPress ever starts; with files present, the WordPress entrypoint does not copy a fresh core over them. MySQL then initialises an empty db_data from the secrets, which recreates the wp user with the current password, and the dump is loaded. Restoring onto a host with different secret values works for that reason. Only after the database holds the data does WordPress start. The script first refuses to run when either volume already exists: extracted over a live wp_html, the archive would mix old and restored files, and the dump would load into a database that already holds a site.

ubuntu@secopslog-docker:~/lab/r-wordpress · Docker 29.8.2
$ ./restore.sh backup
... Container lab-wp-wordpress-1 Healthy
$ curl -s http://localhost:8080/ | grep -o '<title>.*</title>' curl -s http://localhost:8080/wp-content/uploads/proof.txt docker compose ps --format 'table {{.Name}}\t{{.Status}}'
<title>Lab blog</title> uploaded before the backup NAME STATUS lab-wp-db-1 Up 17 seconds (healthy) lab-wp-wordpress-1 Up 5 seconds (healthy)

The title set during installation and the uploaded file are back, and both containers are healthy. Until a restore like this has run, a backup is a hope. Run it on a schedule, against a copy of production, and keep the archives off the host. The guard in restore.sh is the last check here: run it again with the restored volumes in place and it refuses.

ubuntu@secopslog-docker:~/lab/r-wordpress · Docker 29.8.2
$ ./restore.sh backup; echo "exit=$?"
refusing: volume lab-wp_db_data exists; restore only into a project without volumes exit=1
ubuntu@secopslog-docker:~/lab/r-wordpress · Docker 29.8.2
$ docker compose down -v rm -rf secrets backup
... Network lab-wp_default Removed

Running it for real

Pin both images by digest, as the Compose file does, and move the pins forward deliberately, since a moving tag changes your stack without a review. A new WordPress image does not update WordPress itself, though. Core, themes and plugins live in the wp_html volume, and the entrypoint copies core only into an empty volume, so a new image digest brings a new PHP, Apache and PHP extensions and leaves the site's code as it was. WordPress updates its own code instead. Its background updater applies minor (security) releases on its own when it can write its files, as it can here, and major releases, themes and plugins are updated from the dashboard or with WP-CLI (wp core update, wp plugin update --all). Take a backup first. Treat the wp_html volume as untrusted: it is writable by the web server, which makes wp-content/uploads the first place an attacker drops a PHP file. Put TLS in front, keep the database unpublished, and limit the containers as in "Production best practices".

Quick check
01A WordPress stack on a Linux host returns HTTP 500 "Error establishing a database connection". MySQL is healthy. The secret files are -rw------- ubuntu ubuntu. What explains the difference between the two containers?
Incorrect — Both read the same file; the difference is the user that reads it.
Incorrect — The image supports WORDPRESS_DB_PASSWORD_FILE; it just cannot open the file as www-data.
Correct — The bind-mounted file keeps UID 1000 and mode 0600, so www-data gets Permission denied on every request. Use 0644 files in a 0700 directory.
Incorrect — The condition waited for a healthy database; the failure continues long after startup.
02Why does backup.sh stop the WordPress container before dumping the database and archiving wp_html?
Correct — A post saved between the two copies could reference an upload the archive lacks. mysqldump alone would be consistent without stopping anything.
Incorrect — mysqldump with --single-transaction takes a consistent snapshot while clients are connected.
Incorrect — A volume can be mounted by several containers at once, read-only or not.
Incorrect — Bind-mounted secrets are not locked by a running container.
03Restoring onto a new host, a colleague runs docker compose up -d and loads wordpress.sql into the database, and stops there. Posts, titles and settings are back, but every image in the media library is a broken link. What was missed?
Incorrect — The rows loaded; the posts that reference the images are there. What is missing is the files.
Correct — The database only stores references to uploads; the files are in the wp_html archive. Restore it into the volume, ideally before WordPress first starts, as restore.sh does.
Incorrect — The links point at files that do not exist on the new host; no cache is involved.
Incorrect — The recipe uses --single-transaction, and an inconsistent dump would not remove every file.

Try this

Work through “Running it for real” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from recipe: wordpress and mysql with secrets, keep “Running it for real”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related