WordPress needs two things to run: PHP serving the site and a MySQL database behind it. Installing both directly on a server works, but it scatters PHP modules, a web server config, and a database across the host, and moving or rebuilding it later is a chore. Docker Compose keeps the whole thing in one file you can read, version, and bring up with a single command.
This guide builds a complete WordPress site with Compose: the official wordpress image talking to mysql, with named volumes so your posts and uploads survive container rebuilds. You’ll get the working compose file, an explanation of every part, how to keep credentials out of the file, and short sections on putting it behind a reverse proxy and backing it up.
If you’re new to Compose files, the Docker Compose beginner guide covers the syntax this builds on — this article is the practical, production-leaning version of that example.
The two-container shape
A WordPress stack is two services that talk over an internal network:
db— MySQL, holding all the dynamic content: posts, pages, users, settings.web— the WordPress image (PHP plus Apache), serving the site and connecting todb.
WordPress reaches the database by the service name db, not an IP address, because Compose runs an internal DNS resolver on the network it creates. That’s why you’ll see WORDPRESS_DB_HOST: db in the config — db is simply the name of the database service.
The complete compose file
Create an empty folder and save this as compose.yaml. It’s a full, working site.
services:
db:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wpuser
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- db-data:/var/lib/mysql
web:
image: wordpress:6.5
restart: unless-stopped
depends_on:
- db
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wpuser
WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
WORDPRESS_DB_NAME: wordpress
volumes:
- wp-content:/var/www/html/wp-content
volumes:
db-data:
wp-content:
Read top to bottom, it’s a complete description of the site. db runs MySQL 8.0, stores its files in the db-data named volume, and gets its database name and passwords from environment variables. web runs WordPress 6.5, waits for db to be created, publishes container port 80 as 8080 on the host, points WORDPRESS_DB_HOST at the db service, and keeps its uploads, themes, and plugins in the wp-content volume. The volumes: block declares both named volumes so Docker manages them.
Bring it up from that folder:
docker compose up -d
Give MySQL a minute to initialize on the first run, then open http://localhost:8080 and you’ll land on the WordPress installer.
Why the volumes matter
This is the part that separates a site you can keep from one that vanishes on a rebuild. A container’s own filesystem is disposable — recreate the container and anything written inside it is gone. WordPress writes data in two places, and both need a named volume:
db-data→/var/lib/mysqlholds the database: every post, page, comment, user, and setting.wp-content→/var/www/html/wp-contentholds uploaded media, installed themes, and plugins.
Map those to named volumes and the data lives independently of the containers. Update the WordPress image, recreate the container, and the volume reattaches with everything intact.
What lives where in the stack
| db-data volume | MySQL database files — posts, users, settings. Lose this and the site is empty. |
|---|---|
| wp-content volume | Uploads, themes, plugins. Lose this and media and customizations are gone. |
| WordPress core files | Inside the web image; recreated from the image, so no volume needed. |
| Environment variables | DB connection and credentials — set in the file or a .env. |
Keep credentials out of the file
The compose file above uses ${DB_PASSWORD} and ${DB_ROOT_PASSWORD} instead of literal passwords. Compose reads those from a .env file in the same folder automatically, which keeps secrets out of the file you commit to git.
Create .env next to your compose.yaml:
DB_PASSWORD=use-a-strong-password-here
DB_ROOT_PASSWORD=use-a-different-strong-password
Then make sure it never lands in version control:
.env
Don’t publish the database port
Notice that the db service has no ports: section. That’s deliberate. WordPress reaches MySQL over the internal Compose network by the service name db, so the database port never needs to be exposed to the host or the network.
Publishing MySQL with something like -p 3306:3306 would put the database on the host’s interfaces — and on a server with a public IP and no firewall, that can mean an exposed database. The only port that needs to be reachable from outside is WordPress’s web port. Leave MySQL unpublished and it’s only reachable by the WordPress container that needs it.
Putting it behind a domain and HTTPS
Port 8080 is fine for testing, but a real site needs a domain and HTTPS. The clean way to do that is a reverse proxy in front of WordPress, rather than wiring TLS into the WordPress container itself.
The proxy listens on 80 and 443, terminates HTTPS, and forwards requests to the WordPress container over the internal network. WordPress stays on plain HTTP internally and reads the forwarded headers to know the original request was HTTPS. That same proxy can host several sites on one server behind one set of ports. The full setup — shared network, proxy_pass, TLS, and the headers WordPress needs — is in running an Nginx reverse proxy with Docker.
One WordPress-specific note when you move behind a proxy and domain: set the site URL correctly (via the WORDPRESS_CONFIG_EXTRA environment variable or in WordPress settings) so it builds links with your real https:// domain instead of localhost:8080.
Backing up the site
A WordPress Docker site has two things worth backing up, and they need different methods.
The database should be dumped with mysqldump, not copied as raw files — a live database file copy can be inconsistent:
docker exec <db-container-name> \
sh -c 'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" wordpress' \
> wordpress-db-$(date +%F).sql
The wp-content volume (uploads, themes, plugins) is static enough to archive with the tar-through-a-container method:
docker run --rm \
-v wp-content:/data \
-v "$PWD":/backup \
alpine tar czf /backup/wp-content-$(date +%F).tar.gz -C /data .
Together those two files are a full restore point. There’s a complete walkthrough — including restoring and scheduling with cron — in how to back up Docker volumes.
WordPress on Compose — before you call it done
- db-data and wp-content are named volumes (data survives a rebuild)
- Passwords come from a .env file, and .env is in .gitignore
- The db service has no published port — MySQL stays internal
- Image tags are pinned (mysql:8.0, wordpress:6.5), not latest
- A reverse proxy handles the domain and HTTPS for production
- Both the database (mysqldump) and wp-content (tar) are backed up on a schedule
Wrapping up
A WordPress site in Compose is two services — wordpress and mysql — wired together by a service name, with named volumes doing the real work of keeping your data alive across rebuilds. Get four things right and the rest follows: put the database and wp-content on named volumes, keep credentials in a .env file, leave MySQL unpublished, and pin your image tags.
For production, add a reverse proxy for the domain and TLS, and put a backup schedule in place from day one. The pieces connect: the Compose beginner guide for the fundamentals, the reverse proxy guide for HTTPS and multiple sites, and backing up Docker volumes so a bad down -v is recoverable instead of fatal. More container walkthroughs live in the Docker & Containers guides.