WordPress with Docker Compose: A Working Stack
If you have moved WordPress off shared hosting before, you know the drill: unzip files over FTP, create a database, point a config file at it, and hope the permissions line up. A WordPress Docker Compose setup does the same job with one text file and one command, but it trades FTP headaches for a different kind of confusion the first time around. This article gives you a complete, working compose.yaml, walks through why each line is there, and then covers the four things that break the moment you move the stack off your own laptop and onto a real domain. Nobody else seems to mention those four things, which is exactly why people keep hitting them.
You will end up with two containers (WordPress and a database), data that survives a restart, and passwords that never sit in plain text in your file. Copy the working example, run it, and read the explanation after, not before. That order matters more than it sounds like it should.
Building Your WordPress Docker Compose Stack
What You Need Before You Start
Three things, and you likely already have two of them.
- Docker Engine with Compose v2 installed. Run
docker compose versionin a terminal. If you get a version number back, you're set. If you get "command not found," you have the older standalonedocker-compose(with a hyphen) or nothing installed at all, and you'll need to install Docker Desktop or the Docker Engine package for your system first. - An empty folder for the project, for example
wordpress-site/. Nothing needs to exist in it yet. - A terminal you're comfortable typing into. You don't need to know YAML syntax going in. You'll know it by the end of this section just from seeing it work.
The Complete compose.yaml File
Create a file named compose.yaml inside your project folder and paste this in exactly as written:
services:
wordpress:
image: wordpress:7.1-apache
restart: unless-stopped
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: ${DB_USER}
WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
WORDPRESS_DB_NAME: ${DB_NAME}
volumes:
- wp_data:/var/www/html
depends_on:
db:
condition: service_healthy
db:
image: mariadb:11.4
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD: ${DB_PASSWORD}
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 10s
timeout: 5s
retries: 3
volumes:
wp_data:
db_data:
Next to it, create a second file named .env (yes, starting with a dot, no filename before the extension) with your own values:
DB_NAME=wordpress
DB_USER=wp_user
DB_PASSWORD=change-this-password
DB_ROOT_PASSWORD=change-this-too
Pick your own passwords here, not the placeholders. Compose reads this file automatically when it sits next to compose.yaml, which is how the ${DB_USER} style references in the file above get filled in without ever hard-coding a password into version control. If you want a generator to build this shape for you, including the healthcheck and the .env extraction, the Compose generator on this site has a WordPress preset that produces the same file structure and prints the matching .env block below it. If you'd rather understand every interpolation rule first, the site's environment variables and .env guide covers that in more depth than we have room for here.
Line by Line: What Each Service Does
The wordpress service pulls the official image, tagged to a specific version rather than latest. Pinning matters because latest changes underneath you without warning, and a stack that worked yesterday can start failing after a routine pull. As of this writing the current major line is 7.1, built on PHP 8.2 through 8.5 depending on the tag variant. Check the Docker Hub wordpress page for the current tag before you commit to one; version numbers in tutorials go stale, and this one will too eventually.
The wordpress docker environment variables in that block do the actual database handshake: WORDPRESS_DB_HOST points at the other service by its Compose name (db, not an IP address, since Compose gives each service a resolvable hostname on its internal network), and the three that follow feed in the user, password, and database name that match what the db service creates. Beyond these four, the image also accepts WORDPRESS_TABLE_PREFIX, eight WORDPRESS_AUTH_KEY and salt variables for session security, WORDPRESS_DEBUG, and WORDPRESS_CONFIG_EXTRA, which we'll use later to fix the site URL problem. Every one of them also accepts a _FILE suffix that points to a mounted file instead of a plain value, which is how you'd wire in Docker secrets on a production host. Full list on the same Docker Hub documentation page referenced above.
The depends_on block with condition: service_healthy is doing more work than it looks like. Without it, Compose starts both containers at roughly the same moment, and WordPress often reaches for a database that hasn't finished initializing yet, which produces a confusing "error establishing a database connection" on first load even though everything is configured correctly. The condition tells Compose to wait until the database's own healthcheck reports success before starting WordPress at all. If you want the full mechanics of healthchecks and dependency ordering, the site's healthcheck and depends_on guide goes deeper than this article needs to.
Start It and Run the Install Wizard
- From inside the project folder, run
docker compose up -d. You should see Compose create a network, pull both images if you don't already have them, and start two containers. If you instead see "no configuration file provided," you're not in the folder wherecompose.yamllives, so check your working directory. - Run
docker compose ps. Both services should show a status of "running," and thedbrow should say "healthy" once its healthcheck passes, usually within about ten seconds. Ifdbis stuck on "starting" for much longer than that, move to the next step before troubleshooting further. - Run
docker compose logs -f wordpressand watch for errors. A clean stack shows Apache starting with no complaints. If you see "Error establishing a database connection," check that theDB_USER,DB_PASSWORD, andDB_NAMEvalues match between what's referenced incompose.yamland what's actually set in.env. A typo in either file is the most common cause. - Open
http://localhost:8080in a browser. You should land on the WordPress five-minute install screen, asking for a site title, username, and admin password. If the page won't load at all, confirm nothing else on your machine is already using port 8080, since Docker will fail quietly on a port conflict rather than tell you plainly what's wrong. - Complete the install wizard and log in to
/wp-admin. Seeing the WordPress dashboard confirms the whole stack: containers running, database connected, tables created. This is also the exact moment the site URL trap gets set, which the next two sections explain.
Where Your WordPress Files Live in the Docker Volume
Inside the container, WordPress lives at /var/www/html, with themes under /var/www/html/wp-content/themes/ and plugins under /var/www/html/wp-content/plugins/, according to the image's Docker Hub documentation. The wp_data:/var/www/html line in the compose file is what makes that content persist. Without it, every file the container writes disappears the moment the container is removed, because a container's own writable layer is temporary by design, similar to a whiteboard that gets wiped clean whenever the room is reset.
A named volume like wp_data is Docker-managed storage that lives outside any single container's lifecycle. You can inspect where it physically sits on your host with docker volume inspect wordpress-site_wp_data (the name gets prefixed with your project folder name automatically). This is the wordpress docker volume that holds every uploaded image, every installed plugin file, and the core WordPress code itself, since the image copies its bundled files into that mount point the first time the container starts and finds it empty.
Some people prefer bind-mounting only the wp-content folder to a path on the host, so themes and plugins are directly editable from outside the container while the WordPress core code stays inside the named volume. That's a reasonable setup for active development, but it introduces its own file-ownership questions, which the next section touches on and which the site's volumes guide covers in full, including when a bind mount makes more sense than a named volume.
MariaDB or MySQL for Your WordPress Database?
Both work. WordPress officially supports MariaDB 10.11 or newer, or MySQL 8.0 or newer, according to the current requirements page on WordPress.org. The example above uses MariaDB because its image ships a documented healthcheck script (healthcheck.sh), which is what powers the condition: service_healthy wait we set up earlier, and because its official Docker image supports the same architectures WordPress does.
Plenty of people set up a docker compose wordpress mysql pairing instead, and there's nothing wrong with that choice on its own. Just watch out for one specific trap: the widely-copied "official" WordPress Compose sample from a few years back includes a line reading command: '--default-authentication-plugin=mysql_native_password'. That flag was a MySQL-only setting, and MySQL 8.4 removed it entirely (replaced by --authentication-policy), while also disabling the older mysql_native_password authentication method by default. Copy that old sample against a current MySQL image and the database container refuses to start, throwing an error like "Plugin 'mysql_native_password' is not loaded." The fix is simply not to pass that flag: current PHP has supported the newer caching_sha2_password method since PHP 7.4, so a current WordPress image connects to MySQL 8.4 or 9.0 without any special configuration at all. If you go the MySQL route, swap the mariadb image line for mysql:8.0 or newer, adjust the environment variable names to MYSQL_ROOT_PASSWORD, MYSQL_DATABASE, MYSQL_USER, and MYSQL_PASSWORD, and leave the authentication plugin flag out entirely.
Set the Site URL Before the First Run
This is the trap that catches almost everyone moving off localhost, and it's the one piece missing from every tutorial I checked while researching this article. WordPress saves the URL you used to complete the install wizard directly into its database, and it treats that saved URL as the site's permanent home address, the way a return address gets printed on an envelope before you even know where you're mailing it from. Install through http://localhost:8080, then point a real domain at the same containers later, and WordPress keeps redirecting visitors back to localhost:8080 or to a private IP address, regardless of what domain they typed in. People describe this exact symptom in the WordPress Docker image's own issue tracker: one report is titled almost word for word "WordPress Redirects to IP Address Instead of Domain."
Fix it before the first run, not after, by adding WORDPRESS_CONFIG_EXTRA to the wordpress service's environment block:
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: ${DB_USER}
WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
WORDPRESS_DB_NAME: ${DB_NAME}
WORDPRESS_CONFIG_EXTRA: |
define('WP_HOME', 'https://example.com');
define('WP_SITEURL', 'https://example.com');
Swap in your real domain, obviously. The image passes this value through to wp-config.php directly, which is what lets you define constants that don't otherwise have their own dedicated environment variable. If you're already past the install wizard and the redirect problem has already set in, the fix is wp search-replace run against the old URL and the new one, with the --skip-columns=guid flag so it doesn't touch the internal GUID field WordPress uses for feed identifiers. WordPress's own developer documentation on migrating a site covers the full command and why that flag matters.
One more piece if you're sitting behind a reverse proxy or a service like Cloudflare handling HTTPS in front of your containers: the WordPress image automatically adds a snippet to wp-config.php that trusts the X-Forwarded-Proto header whenever any WORDPRESS_* variable is set, so make sure your proxy actually sends that header, or WordPress will think every request arrived over plain HTTP and generate the wrong kind of links throughout the site. After you point a real domain at the stack, it's worth confirming the site actually answers on that domain with a 200 status rather than quietly redirecting somewhere odd, before you consider the migration finished.
Three Things That Bite Once You Leave Localhost
Beyond the site URL, three more issues show up specifically once the stack moves to a VPS or a home server serving real visitors.
Upload permission errors. If you bind-mount an uploads folder from the host instead of relying on the named volume, and that host folder is owned by your own user account (commonly UID 1000), you'll eventually hit "The uploaded file could not be moved to wp-content/uploads." The WordPress container runs as the www-data user internally, which is UID 33 on the Debian-based image variants, not UID 1000. People chase this by trying chmod 755 or changing ownership to nginx or other guesses that don't match the actual UID inside the container, which is the real source of the mismatch. The reliable fix is chown -R 33:33 on the mounted path from the host, or simpler still, keep uploads on the named volume from the compose file above and let the image's own startup process own it correctly from the start.
The 2 MB upload ceiling. PHP's default upload_max_filesize is 2M and post_max_size is 8M, according to the PHP manual, and the official WordPress image doesn't raise either one. Every image you try to upload past that size fails silently in the media library with a vague error (compressing the image first is a workaround, not a fix). The fix is a small custom ini file dropped into $PHP_INI_DIR/conf.d/, which the next section shows how to bake into your own image.
Secrets sitting in plain files. The .env approach in this article keeps passwords out of compose.yaml itself, which is already better than hard-coding them, but .env is still a plain text file on disk. For a production server, Docker's secrets mechanism combined with the _FILE suffix on the WORDPRESS_DB_PASSWORD and MARIADB_PASSWORD variables keeps credentials out of environment listings entirely. That's a bigger topic than fits here.
Optional: A Dockerfile for WordPress
You don't need a Dockerfile at all for the setup above; the official image covers it. But if you want the upload size fix baked in permanently, or a theme copied in automatically instead of mounted, a small custom dockerfile wordpress build handles both in four lines:
FROM wordpress:7.1-apache
COPY custom.ini $PHP_INI_DIR/conf.d/
COPY ./my-theme /var/www/html/wp-content/themes/my-theme
With custom.ini sitting next to the Dockerfile containing:
upload_max_filesize = 64M
post_max_size = 64M
memory_limit = 256M
Then in compose.yaml, replace image: wordpress:7.1-apache under the wordpress service with build: . so Compose builds this Dockerfile instead of pulling the stock image. The site's Dockerfile generator doesn't have a dedicated WordPress preset, but its PHP preset produces the same general multi-stage shape with a non-root user and a healthcheck, and is a reasonable starting point if you want to extend this further.
Check the File Before You Ship It
- Run
docker compose configfrom inside your project folder. This resolves all your${...}variables and prints the fully expanded configuration. Read through it once. If a password shows up as an empty value instead of the real one, your.envfile isn't in the same folder ascompose.yaml, or a variable name doesn't match between the two files. - Copy the raw
compose.yaml(not the expanded output) into the Compose validator on this site. It flags unpinned image tags, missing healthchecks on services that other services depend on, and roughly forty other common mistakes, right down to the specific line number. - Fix anything flagged as an error before you deploy to a real server, and treat warnings as worth a second look even if you decide to ignore them. A clean validator result, combined with both containers showing "healthy" in
docker compose ps, is a reasonable green light to move the stack to a VPS.
Frequently Asked Questions
How do I run WordPress with Docker Compose?
Create a compose.yaml with two services, one for WordPress and one for a MariaDB or MySQL database, add a named volume for each so data survives restarts, connect them with a healthcheck-aware depends_on, then run docker compose up -d from the same folder. Visit the published port in a browser (for example http://localhost:8080) to complete the standard WordPress install wizard. The complete working file earlier in this article covers the exact setup, and the site's Compose generator can produce the same shape for you with a WordPress preset.
Where are WordPress files stored in a Docker volume?
Inside the container, WordPress lives at /var/www/html, with themes and plugins under the wp-content subfolder, per the official image's Docker Hub documentation. Mounting a named volume like wp_data:/var/www/html in your compose file keeps that content on disk even when the container is removed or rebuilt. You can find exactly where Docker stores that volume on your host machine by running docker volume inspect with the volume's full name.
Which database image should I use with WordPress?
Either MariaDB 10.11 or newer, or MySQL 8.0 or newer, both meet WordPress's official requirements. MariaDB is the slightly easier default because its image includes a documented healthcheck script, which pairs naturally with a depends_on: condition: service_healthy setup. If you choose MySQL instead, avoid the old --default-authentication-plugin=mysql_native_password flag still floating around in outdated tutorials; MySQL 8.4 and newer no longer accept it, and current WordPress images don't need it anyway.
How do I set the site URL in a Docker WordPress?
Add a WORDPRESS_CONFIG_EXTRA environment variable to the WordPress service before the first container start, containing define('WP_HOME', 'https://yourdomain.com'); and define('WP_SITEURL', 'https://yourdomain.com'); with your real domain in place of the placeholder. Set this before running the install wizard, since WordPress saves the URL used during setup into the database and keeps redirecting to it afterward. If the site has already been installed under the wrong URL, running wp search-replace with the old and new URLs, using the --skip-columns=guid flag, corrects it after the fact.