CoursesDocker in depthRecipe: WordPress + MySQL

Recipe: WordPress + MySQL

A two-service stack wired with Compose.

Intermediate12 min · lesson 28 of 30

Two containers, one blog. WordPress runs the PHP (PHP: Hypertext Preprocessor, the language the site is written in) and serves every page a visitor asks for. MySQL, the database server, holds the posts, the users and the settings. The two start out as strangers: separate processes that have to find each other, agree on a database password, and hang onto their data when one of them gets replaced. Compose, the Docker tool that runs several containers as a single unit, is how you write all of that down in one file instead of a drawer full of shell scripts.

What you're wiring together

Four pieces, and that is the whole stack. One container runs WordPress, with PHP and the Apache web server already baked into the official image. One container runs MySQL. Two named volumes (a volume is Docker's word for a storage area that outlives the container using it) hold the files WordPress writes and the database on disk, so a docker compose down followed by another up does not wipe your content. And two small files hold the database passwords. Both services read the same files, so the app and the database can never end up disagreeing about what the password is.

Diagram
Reachable from your machine
Browser
http://localhost:8080
wordpress
PHP + Apache, publishes 8080 to 80
Private Compose network only
db
MySQL 8.4, no published port
wp_html volume
themes, plugins, uploads
db_data volume
tables and rows on disk

That split is the point of the whole layout. Only WordPress gets a published port, meaning a door on your machine that the outside world can knock on. MySQL sits on the private Compose network, where nothing outside the stack can reach it. Your database is not on the internet, and that is deliberate.

Start with the passwords

MySQL will not let anyone in without a password, and you do not want that value sitting in the compose file, because the compose file goes into git where the whole team can read it. Give each password its own file and let Compose mount it into both containers under /run/secrets. While you are here, generate something random rather than typing password123.

terminal
$ mkdir -p secrets
$ openssl rand -base64 24 > secrets/wp_db_pw.txt
$ openssl rand -base64 24 > secrets/wp_root_pw.txt
$ chmod 600 secrets/*.txt
$ ls -l secrets/
total 8
-rw------- 1 me me 33 Jul 17 09:14 wp_db_pw.txt
-rw------- 1 me me 33 Jul 17 09:14 wp_root_pw.txt

The file that wires it up

Here is the lot. Two services, two volumes, two secrets, and a healthcheck (a command Docker runs inside the container to ask "are you actually working?") so WordPress waits for a database that can answer, not one that has merely started.

compose.yaml
services:
db:
image: mysql:8.4
restart: unless-stopped
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wp
MYSQL_PASSWORD_FILE: /run/secrets/wp_db_pw
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/wp_root_pw
secrets: [wp_db_pw, wp_root_pw]
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
wordpress:
image: wordpress:6-php8.3-apache
restart: unless-stopped
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wp
WORDPRESS_DB_PASSWORD_FILE: /run/secrets/wp_db_pw
secrets: [wp_db_pw]
volumes:
- wp_html:/var/www/html
depends_on:
db:
condition: service_healthy
volumes:
wp_html: {}
db_data: {}
secrets:
wp_db_pw:
file: ./secrets/wp_db_pw.txt
wp_root_pw:
file: ./secrets/wp_root_pw.txt

Four settings are doing the real work. WORDPRESS_DB_HOST: db points WordPress at the database by service name. Compose runs its own DNS (Domain Name System, the phone book that turns names into addresses) on the private network, so db resolves to the database container, the way you call a coworker by name instead of memorising their desk number. The _FILE variants tell each image to read the password out of the mounted secret rather than an environment variable, so the value never turns up in docker inspect. depends_on with condition: service_healthy holds WordPress back until MySQL's healthcheck passes; a plain depends_on waits only for the container to exist, which is how you land on the 'Error establishing a database connection' page on the very first boot. And restart: unless-stopped brings the stack back after a reboot or a crash, while still leaving it down if you stopped it on purpose.

Bring it up

One command starts both containers in the right order. Compose creates the network and the volumes on the way past, then sits on the database healthcheck until it goes green before it lets WordPress start.

terminal
$ docker compose up -d
[+] Running 5/5
✔ Network wp_default Created
✔ Volume wp_db_data Created
✔ Volume wp_wp_html Created
✔ Container wp-db-1 Healthy
✔ Container wp-wordpress-1 Started
$ docker compose ps
NAME IMAGE STATUS PORTS
wp-db-1 mysql:8.4 Up 41 seconds (healthy)
wp-wordpress-1 wordpress:6-php8.3-apache Up 10 seconds 0.0.0.0:8080->80/tcp

Look at the db row: nothing under PORTS. It is up, it is healthy, and it is not published. Exactly what you asked for.

Prove it works

Two checks, and you need no browser for either. Curl the site and see whether WordPress answers, then log in to the database as the application user and run a single query. If both come back clean, the wiring is right.

terminal
$ curl -sI localhost:8080 | head -n 2
HTTP/1.1 302 Found
Location: http://localhost:8080/wp-admin/install.php
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/wp-admin/install.php
200
$ docker compose exec -T db \
sh -c 'mysql -uwp -p"$(cat /run/secrets/wp_db_pw)" wordpress \
-e "SELECT DATABASE() AS db, VERSION() AS version;"'
mysql: [Warning] Using a password on the command line interface can be insecure.
+-----------+---------+
| db | version |
+-----------+---------+
| wordpress | 8.4.10 |
+-----------+---------+

The 302 is WordPress bouncing a brand new site to its installer, and the 200 is that installer loading. The query proves the wp user really can log in to the wordpress database across the private network. It reads the password straight out of the mounted secret, so the value never passes through your keyboard. Open a browser from here and the five minute install finishes the job.

MySQL reads the password once, on the very first boot
MySQL looks at MYSQL_USER, MYSQL_PASSWORD and MYSQL_ROOT_PASSWORD only while it is setting up an empty data directory. Once db_data holds real data, editing the secret file changes nothing and the next start carries on with the old password. So if you rotate the file and immediately get 'Access denied for user wp', that is your explanation. Changing it for real means running ALTER USER 'wp'@'%' IDENTIFIED BY '...' inside MySQL, or deleting the volume and losing the site along with it. The same rule is why db_data is the one thing you cannot afford to lose. Back it up, because losing that volume loses every post.

Two services, one network, a database that survives

This pair is the reference Compose stack, the one most people build first. Web and database on a user-defined network, a volume for the WordPress files and a volume for the database files, secrets holding the credentials, and a healthcheck so WordPress starts only once MySQL can answer. Publish the web port and nothing else.

Running it yourself buys you the learning and hands you the patching. Managed WordPress hosting is the other side of that trade: less to understand, less to break. Own it and you own the core and plugin updates, the XML-RPC exposure (an old WordPress remote control endpoint that bots hammer around the clock), and the backups of both volumes. Pin image digests so a rebuild gets you the exact bytes you tested. WordPress plugins are a supply chain of their own, with their own bad days.

Baseline hardening for this stack: a long random database password, no public database port, core and plugin updates on a schedule you actually keep, and TLS (Transport Layer Security, the encryption behind https) terminated at a reverse proxy in front. Treat the uploads volume as hostile ground. It is the first place an attacker drops a PHP file once they find a way in.

Run the drill before you need it. Take the stack down, bring it back up, then log in and check that your posts and users are still there. If they are gone, your volume mapping was decoration rather than storage. Write the restore-from-backup steps down while the site is healthy, not at 2am when it is not.

Keep a short record whenever you run this on something real. The image digests for wordpress:6-php8.3-apache and mysql:8.4 before and after, the host you ran docker compose up -d on, and the one command that puts it back the way it was. A boring reversible step with the docker compose ps output pasted underneath beats a clever one-liner nobody else can read. If a teammate cannot stand the stack back up from your ticket alone, add the compose file and the healthy STATUS lines you saw, and then it is finished.

Try this

Run these on a lab engine; Docker 24 or newer is fine. Read the sample output first so you know what a healthy stack looks like before you lean on these commands anywhere that matters.

terminal
$ docker compose -f wp.yml up -d
$ docker compose -f wp.yml ps
NAME STATUS
wp_db Up (healthy)
wp_wordpress Up
$ curl -s -o /dev/null -w '%{http_code}
' http://127.0.0.1:8080/
302
# STATUS: READY — DB healthy; WP answering

Takeaway

WordPress and MySQL on one private Compose network, a volume under each tier, secrets carrying the passwords, and port 8080 as the only door. Then run the down-and-up drill until you genuinely trust that wp_html and db_data are keeping your site.

Quick check
01With depends_on: { db: { condition: service_healthy } } in place, what has to happen before the wordpress container starts?
Incorrect — That is what a bare depends_on gives you, and it is exactly how first-boot database errors happen.
Correct — The service_healthy condition holds WordPress at the gate until the healthcheck passes.
Incorrect — The wizard runs long after this. depends_on controls container start order, not application setup.
Incorrect — links is Compose v1 baggage and is not needed here. Services already find each other by name on the network.
02In docker compose ps the db row shows nothing under PORTS, while wordpress shows 0.0.0.0:8080->80/tcp. What is that empty PORTS column telling you about MySQL?
Incorrect — The same row reads Up (healthy), so it started cleanly. The blank PORTS column is on purpose.
Incorrect — No host port was published at all, so docker port has nothing to report.
Correct — With no ports: entry the database stays off your machine and off the internet, which is the whole idea.
Incorrect — Without a ports: mapping nothing reaches the host. The container port is live only inside the Compose network.
03Your site is already full of posts. You rotate the database password by editing secrets/wp_db_pw.txt and running docker compose up -d, and WordPress starts showing 'Error establishing a database connection'. What went wrong, and how do you fix it?
Incorrect — The secret mounts fine. The real problem is that MySQL never re-reads it after that first initialization.
Correct — The credential lives inside the populated db_data volume, so you rotate it in MySQL, not in the secret file.
Incorrect — Deleting wp_html throws away themes and uploads and fixes nothing. The password lives in MySQL's data volume.
Incorrect — The database is healthy. This is an authentication mismatch, not a startup-ordering problem.

Related