Recipe: WordPress + MySQL
A two-service stack wired with Compose.
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.
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.
$ 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.
services:db:image: mysql:8.4restart: unless-stoppedenvironment:MYSQL_DATABASE: wordpressMYSQL_USER: wpMYSQL_PASSWORD_FILE: /run/secrets/wp_db_pwMYSQL_ROOT_PASSWORD_FILE: /run/secrets/wp_root_pwsecrets: [wp_db_pw, wp_root_pw]volumes:- db_data:/var/lib/mysqlhealthcheck:test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]interval: 10stimeout: 5sretries: 5start_period: 30swordpress:image: wordpress:6-php8.3-apacherestart: unless-stoppedports:- "8080:80"environment:WORDPRESS_DB_HOST: dbWORDPRESS_DB_NAME: wordpressWORDPRESS_DB_USER: wpWORDPRESS_DB_PASSWORD_FILE: /run/secrets/wp_db_pwsecrets: [wp_db_pw]volumes:- wp_html:/var/www/htmldepends_on:db:condition: service_healthyvolumes:wp_html: {}db_data: {}secrets:wp_db_pw:file: ./secrets/wp_db_pw.txtwp_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.
$ 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 psNAME IMAGE STATUS PORTSwp-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.
$ curl -sI localhost:8080 | head -n 2HTTP/1.1 302 FoundLocation: http://localhost:8080/wp-admin/install.php$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/wp-admin/install.php200$ 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.
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.
$ docker compose -f wp.yml up -d$ docker compose -f wp.yml psNAME STATUSwp_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.
depends_on: { db: { condition: service_healthy } } in place, what has to happen before the wordpress container starts?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?Up (healthy), so it started cleanly. The blank PORTS column is on purpose.docker port has nothing to report.ports: entry the database stays off your machine and off the internet, which is the whole idea.ports: mapping nothing reaches the host. The container port is live only inside the Compose network.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?