TL;DR
- Docker’s real win for WordPress is parity and onboarding: the same PHP version, extensions, and MySQL everyone runs, started with one command instead of a day of setup.
- The traps are WordPress-specific, not Docker-specific:
DB_HOSTis the service name notlocalhost, WordPress stores absolute URLs in the database, and the container’s user ID fights your file permissions. - Bind-mount your code; never bind-mount
vendor/,wp/, ornode_modules; keep the database in a named volume so it survivesdocker compose down. - This covers the local development workflow. For the git-pull production side on shared hosting, see the Bedrock deploy guide linked at the end.
The “works on my machine” bug that finally pushed me to Docker for WordPress was a PHP extension. A plugin needed intl, it was present on my Mac and missing on a teammate’s, and we lost an afternoon to a white screen that only one of us could reproduce. That is the class of problem containers remove: not by magic, but by making the runtime a file in the repo instead of a property of someone’s laptop.
This article is the local development half of running WordPress in Docker — the parity, the workflow, and the WordPress-specific traps that generic Docker tutorials skip. The production deploy on shared hosting is a different problem with its own sharp edges, and it has its own guide at the end.
What Docker actually fixes in WordPress development
WordPress is unusually exposed to environment drift. It runs on a PHP version, a set of extensions, a MySQL version, and a web server, and a plugin can quietly depend on any of them. Containers pin all four in version-controlled config, so the stack is identical for everyone and reproducible in CI.
The concrete payoffs, in order of how much they matter day to day:
Onboarding
A new developer runs one command and has the full stack, instead of a README full of brew installs.
Version parity
Pin PHP and MySQL to match production, so “works locally” means something.
Disposable state
Break the database, drop the volume, reimport. No fear of corrupting a local install.
Plugin testing
Spin a throwaway stack to test a plugin or PHP upgrade without touching your real setup.
A development stack that is actually usable
The single-container demo everyone shows you is fine for a five-minute look and useless for real work:
docker run --name wp -p 80:80 -d wordpress:latestIt has no persistent database, no access to your theme and plugin code, and no way to run WP-CLI comfortably. Real development needs a multi-service stack with your code mounted in and the database persisted:
services:
app:
image: wordpress:php8.3-apache
ports:
- "8880:80"
volumes:
- ./wp-content:/var/www/html/wp-content
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
depends_on:
- db
db:
image: mysql:8.0
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: wordpress
MYSQL_ROOT_PASSWORD: root
volumes:
- db_data:/var/lib/mysql
volumes:
db_data:Two decisions in there matter more than they look. The PHP version is pinned in the image tag to match production — drift between local PHP 8.3 and a production 8.1 is exactly the bug Docker is supposed to prevent, so do not leave it floating on latest. And the database lives in the named volume db_data, which survives docker compose down; the code is a bind mount so your editor changes are live.
The WordPress-specific traps
These are the issues that are not in a generic Docker tutorial because they are WordPress problems that happen to surface inside Docker.
DB_HOST is the service name, not localhost
Inside the Compose network, containers find each other by service name. The database host is db, not localhost and not 127.0.0.1. Setting it to localhost makes WordPress try a socket that does not exist in the app container, and you get “Error establishing a database connection” with no further detail.
WordPress stores absolute URLs in the database. The siteurl and home options, and URLs baked into post content, are full http://localhost:8880 strings. Import a production database dump locally and every link, image, and redirect still points at production. Fix it with a serialization-safe search-replace, never a raw SQL UPDATE that corrupts serialized data:
docker compose exec app wp search-replace 'https://example.com' 'http://localhost:8880' --all-tables --skip-columns=guidFile ownership fights you. The Apache image runs as www-data (UID 33). Files it writes — uploads, generated CSS, plugin installs — end up owned by UID 33 on your host, and files you create as your host user may not be writable by the container. On Linux especially this shows up as upload failures. The clean fix is to align the container’s user with your host UID, or to keep writable directories (uploads) on a volume the container owns while your code stays a plain bind mount.
Run WP-CLI through the container. WP-CLI needs the same PHP and database the site uses, so run it inside the app container, not on your host:
docker compose exec app wp plugin list
docker compose exec app wp db export backup.sqlWhat not to mount
Bind mounts are how your code reaches the container, but mounting the wrong directories is the most common way to slow the stack down or break it outright.
Mounting vendor/ or wp/
Composer-managed directories should live in the image, not be overridden by host files. Mounting them invites version mismatches.
Mounting node_modules
Host and container architectures differ. Build assets inside the container or keep node_modules out of the mount.
Floating PHP version
Using wordpress:latest instead of a pinned PHP tag reintroduces the exact drift Docker is meant to remove.
Database in a bind mount
MySQL data on a host bind mount is slow and permission-prone. Use a named volume.
Committing uploads
wp-content/uploads is user data, not code. Keep it out of git and out of the image.
localhost for the DB host
Containers reach the database by service name. localhost points the app container at itself.
Optional services worth adding
Once the core stack works, a few extra services make local development noticeably better. A mail catcher like Mailpit captures outgoing email so password resets and notifications land in a web UI instead of the void. Redis gives you a real object cache that matches production behavior. A dedicated queue worker container lets you exercise jobs the way they actually run. Each is a few lines in the same Compose file, version-controlled alongside the rest.
FAQ
- Why does WordPress show “Error establishing a database connection” in Docker?
- Almost always the database host. Inside Docker Compose, services reach each other by service name, so
DB_HOSTmust be the database service name (db), notlocalhost. A secondary cause is the app container starting before MySQL is ready;depends_onplus a retry on connection handles that. - Why are my image URLs wrong after importing a production database?
- WordPress stores absolute URLs in the database. Run a serialization-safe
wp search-replacefrom the production domain to your local URL across all tables, skipping theguidcolumn. Never do this with a raw SQLUPDATE, which corrupts serialized option data. - Should I mount vendor/ and wp/ as volumes?
- No. Composer-managed directories belong in the image so versions stay consistent. Bind-mount only your own code (themes, plugins, mu-plugins). Mounting managed directories is a common source of “it works in CI but not locally” mismatches.
- How do I run WP-CLI in a Dockerized WordPress site?
- Run it inside the app container with
docker compose exec app wp <command>so it uses the same PHP version and database connection as the site. A Makefile wrapper around that command saves a lot of typing.
Related articles
- WordPress Bedrock with Docker locally and bare PHP in production covers the deploy half: git-pull releases to shared hosting without Trellis or a VPS.
- Maximizing performance with Nginx and Apache covers the web-server layer you can benchmark cleanly once the stack is reproducible.




