Recipe: WordPress and MySQL with secrets
Two services, secrets WordPress can read, health checks, and a tested backup.
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: 0b2199906f8f5add5845546bca334e05c111262e6d81d1be23f1350a4e8f7679This 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
name: lab-wpservices:db:image: mysql:8.4@sha256:6ea90827b1100f8f2ae306a539f86d2c264a26ed435a2a9f75551dd5c3aeb242environment:MYSQL_DATABASE: wordpressMYSQL_USER: wpMYSQL_PASSWORD_FILE: /run/secrets/db_passwordMYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_passwordMYSQL_ROOT_HOST: localhostsecrets: [db_password, db_root_password]volumes:- db_data:/var/lib/mysqlhealthcheck:test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]interval: 5stimeout: 3sretries: 10start_period: 60s# time for InnoDB to flush and shut down cleanly before Docker sends SIGKILL (default 10s)stop_grace_period: 1mrestart: unless-stoppedwordpress:image: wordpress:php8.4-apache@sha256:2007075ffdfcdbf6cee457615658f2f02ed34722f0a599c2f80c43fbd0be31b3ports:- "127.0.0.1:8080:80"environment:WORDPRESS_DB_HOST: dbWORDPRESS_DB_NAME: wordpressWORDPRESS_DB_USER: wpWORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_passwordsecrets: [db_password]volumes:- wp_html:/var/www/htmldepends_on:db:condition: service_healthyhealthcheck:test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1/"]interval: 10stimeout: 5sretries: 3start_period: 20srestart: unless-stoppedvolumes:db_data:wp_html:secrets:db_password:file: ./secrets/db_password.txtdb_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:
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:
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:
# 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_passworduid: "33"gid: "33"mode: 0400
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:
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
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:
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
#!/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 -euumask 077mkdir -p "$1"dir=$(realpath "$1")trap 'docker compose start wordpress' EXITdocker 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:
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:
Now lose everything. down -v removes the containers, the network and both volumes:
#!/bin/sh# Usage: ./restore.sh DIR (into a project with no volumes yet: after down -v, or on a new host)set -eudir=$(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; doif docker volume inspect "$v" >/dev/null 2>&1; thenecho "refusing: volume $v exists; restore only into a project without volumes" >&2exit 1fidone# Create the network, both volumes and the containers without starting anything.docker compose createdocker 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 dbdocker compose exec -T db sh -c 'mysql --defaults-extra-file=/dev/fd/3 3<<EOT[client]user=rootpassword=$(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.
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.
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".
-rw------- ubuntu ubuntu. What explains the difference between the two containers?WORDPRESS_DB_PASSWORD_FILE; it just cannot open the file as www-data.backup.sh stop the WordPress container before dumping the database and archiving wp_html?--single-transaction takes a consistent snapshot while clients are connected.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?wp_html archive. Restore it into the volume, ideally before WordPress first starts, as restore.sh does.--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.