
How to Set Up Local WordPress Development with Docker?
Docker gets a WordPress site with the exact PHP, MySQL, and web server versions your production host runs, running locally in minutes, without installing any of it directly on your machine, and without one project's PHP 8.3 requirement colliding with another project still stuck on PHP 7.4. Each project gets its own isolated, disposable environment defined entirely in a couple of text files that live in the repository itself.
The official WordPress Docker image and the official MySQL image are both maintained directly by their respective projects, and docker compose is what wires them together, plus a database admin UI and your local theme/plugin code, into one command you run to bring the whole stack up.
This guide covers a complete docker-compose.yml for WordPress, MySQL, and phpMyAdmin, mounting your theme or plugin code so edits show up instantly, running WP-CLI inside the container, and a few of the most common gotchas (file permissions, port conflicts, data persistence) people hit on their first setup.
Prerequisites
Install Docker Desktop (macOS, Windows) or Docker Engine plus the Compose plugin (Linux). Verify both are available:
docker --version
docker compose version
Step 1: Project Structure
my-wordpress-site/
├── docker-compose.yml
├── .env
└── wp-content/
├── themes/
│ └── my-theme/
└── plugins/
└── my-plugin/
Only wp-content needs to exist on your machine ahead of time; WordPress core itself, the database, and everything else get created automatically by the containers on first run.
Step 2: Write the docker-compose.yml
services:
db:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: wordpress
MYSQL_ROOT_PASSWORD: root_password_change_me
volumes:
- db_data:/var/lib/mysql
ports:
- "3306:3306"
wordpress:
image: wordpress:6-php8.3-apache
restart: unless-stopped
depends_on:
- db
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WORDPRESS_DEBUG: 1
volumes:
- wp_data:/var/www/html
- ./wp-content/themes:/var/www/html/wp-content/themes
- ./wp-content/plugins:/var/www/html/wp-content/plugins
ports:
- "8000:80"
phpmyadmin:
image: phpmyadmin:latest
restart: unless-stopped
depends_on:
- db
environment:
PMA_HOST: db
PMA_USER: wordpress
PMA_PASSWORD: wordpress
ports:
- "8080:80"
volumes:
db_data:
wp_data:
A few decisions in this file worth understanding:
depends_ononly controls startup order (startingdbbeforewordpress), not whether MySQL is actually ready to accept connections yet. The official WordPress image handles this gracefully on its own by retrying the database connection for a short period on boot, so an explicit healthcheck usually isn't necessary for local development, though it's worth adding for anything closer to production.- Named volumes (
db_data,wp_data) persist data acrossdocker compose downand container recreation. Without them, everydocker compose down(as opposed todocker compose stop) would wipe the database and any WordPress core files installed inside the container. - Bind-mounting only
wp-content/themesandwp-content/plugins, rather than the entire WordPress installation, is deliberate: core files stay inside thewp_datavolume, managed by the image, while your actual project code (themes and plugins) lives in the repository and is what git tracks and edits reflect immediately. WORDPRESS_DEBUG: 1turns onWP_DEBUGfor local development, surfacing PHP notices and deprecation warnings that are silenced by default in production.
Step 3: Bring the Stack Up
docker compose up -d
WordPress becomes available at http://localhost:8000, and phpMyAdmin at http://localhost:8080 for inspecting the database directly. First visit to port 8000 shows the standard WordPress installation wizard, since the database exists but hasn't been populated with WordPress's tables yet.
# View logs from all services, useful when something isn't starting correctly
docker compose logs -f
# Stop the containers without deleting data
docker compose stop
# Stop and remove containers (volumes, and therefore data, are preserved)
docker compose down
# Stop and remove containers AND delete all data volumes
docker compose down -v
Step 4: Run WP-CLI Inside the Container
Rather than installing WP-CLI separately on the host machine, run it inside the running wordpress container with docker compose exec:
docker compose exec wordpress wp core install \
--url="http://localhost:8000" \
--title="My Local Site" \
--admin_user=admin \
--admin_password=admin \
--admin_email=admin@example.test \
--skip-email
docker compose exec wordpress wp plugin install advanced-custom-fields --activate
docker compose exec wordpress wp theme activate my-theme
The official wordpress image doesn't ship with WP-CLI preinstalled, so the first wp command inside the container will fail with "command not found" unless it's added. A dedicated wp-cli service using the official wp-cli image avoids needing to install it manually:
wpcli:
image: wordpress:cli
depends_on:
- db
- wordpress
volumes:
- wp_data:/var/www/html
- ./wp-content/themes:/var/www/html/wp-content/themes
- ./wp-content/plugins:/var/www/html/wp-content/plugins
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
entrypoint: ["wp"]
command: ["--info"]
With this service added, running any command becomes:
docker compose run --rm wpcli plugin list
docker compose run --rm wpcli search-replace 'http://localhost:8000' 'http://mysite.local'
docker compose run --rm starts a fresh, disposable container for the single command and removes it afterward, which is the right tool here since WP-CLI commands are one-shot operations, not long-running services like the web server.
Step 5: Import an Existing Database
For working against a copy of real site content rather than a fresh install, copy a .sql export into the project and import it through the same wpcli service:
docker compose cp backup.sql wordpress:/tmp/backup.sql
docker compose exec wordpress wp db import /tmp/backup.sql
docker compose exec wordpress wp search-replace 'https://production-site.com' 'http://localhost:8000'
The search-replace step afterward is essential, not optional, an imported production database still contains production URLs throughout serialized options and post content, and without correcting them, links, images, and the admin AJAX URL all silently point back at the live site instead of your local copy.
Step 6: Enable Xdebug for Step Debugging
For actual PHP debugging rather than error_log() calls, swap in an image variant with Xdebug, or extend the base image:
# Dockerfile
FROM wordpress:6-php8.3-apache
RUN pecl install xdebug && docker-php-ext-enable xdebug
COPY xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini
; xdebug.ini
xdebug.mode=debug
xdebug.start_with_request=yes
xdebug.client_host=host.docker.internal
xdebug.client_port=9003
Then point the wordpress service at this custom Dockerfile instead of the stock image:
wordpress:
build: .
# ...rest of the service definition unchanged
host.docker.internal is Docker Desktop's special DNS name that resolves back to your host machine from inside a container, which is what lets Xdebug running in the container reach your IDE's debug listener running on your actual laptop.
Step 7: Back Up and Restore Volumes
Named Docker volumes live outside your project directory (managed by the Docker daemon itself), so they don't get backed up just by copying your repository. Exporting one to a portable tarball uses a temporary throwaway container that mounts both the volume and your local filesystem:
# Back up the wp_data volume to a local tarball
docker run --rm \
-v my-wordpress-site_wp_data:/data \
-v "$(pwd)":/backup \
alpine tar czf /backup/wp_data_backup.tar.gz -C /data .
# Restore it into a fresh, empty volume
docker run --rm \
-v my-wordpress-site_wp_data:/data \
-v "$(pwd)":/backup \
alpine tar xzf /backup/wp_data_backup.tar.gz -C /data
The volume name (my-wordpress-site_wp_data here) is prefixed automatically by Compose with the project's directory name unless you set an explicit name: under the volumes: key in docker-compose.yml; run docker volume ls if you're unsure what a given project's volumes actually got named. Since the database itself is more naturally backed up with wp db export (covered in the WP-CLI guide) rather than a raw volume tarball, in practice this volume-backup approach is used most often for the wp_data volume holding WordPress core files and uploads, not db_data.
Step 8: A Multi-Environment Setup with an Override File
Compose supports layering a second file on top of the base docker-compose.yml, useful for keeping production-shaped defaults in the main file while overriding a few values for local convenience, like exposing extra ports or mounting additional debug tooling:
# docker-compose.override.yml (loaded automatically alongside docker-compose.yml)
services:
wordpress:
environment:
WORDPRESS_DEBUG: 1
WORDPRESS_CONFIG_EXTRA: |
define( 'SCRIPT_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
ports:
- "8000:80"
- "9003:9003" # Xdebug
Docker Compose automatically merges docker-compose.override.yml with docker-compose.yml if both exist in the same directory, with no extra flag needed, which is why it's a common convention to keep genuinely shared, production-safe defaults in the base file and commit the override file separately (or .gitignore it entirely) for anything specific to an individual developer's machine.
Common Gotchas
- Port already in use. If
8000or3306is already bound by another local service (a system-installed MySQL, for instance), change the left-hand side of the port mapping, e.g."8001:80", and adjust the URL you visit accordingly. - File permission errors on Linux hosts. Files created inside the container by the
www-datauser can end up owned by a UID that doesn't match your host user, making them awkward to edit outside Docker. Runningdocker compose exec wordpress chown -R www-data:www-data /var/www/html/wp-contentfixes ownership after the fact, or configure a matching UID via a build argument for a more permanent fix. - Nothing shows up after "cloning"
wp-contentfrom a live site. If youcpa fullwp-contentfolder includinguploadson top of the mounted volumes, make sure the mount paths indocker-compose.ymlactually point at the folder you populated, a common mistake is bind-mounting./wp-contentin one place while manually placing files under a differently-named local folder.
Frequently Asked Questions (FAQ) About Docker WordPress Development
No, that's the entire point of containerizing the stack. Both PHP (bundled inside the wordpress image) and MySQL run inside their own containers, isolated from whatever, if anything, is installed directly on your host machine.
Yes, as long as they live under the bind-mounted wp-content/themes and wp-content/plugins directories shown in Step 2. Since those are live mounts of your local filesystem rather than files baked into the image, saving a file locally updates it inside the container instantly, no rebuild or restart needed.
Nothing is lost as long as your docker-compose.yml defines named volumes (db_data and wp_data in the example above) and you don't pass the -v flag. down removes the containers themselves but leaves named volumes, and therefore your database and WordPress core files, intact for the next docker compose up.
Yes, change the tag on the wordpress image, for example wordpress:6-php8.1-apache instead of php8.3-apache, to match whatever version your production host runs, catching version-specific bugs locally before they reach a live site.
It can be adapted for production, but the compose file shown here is intentionally tuned for local development: it uses simple hardcoded passwords, exposes the database port directly, and skips a reverse proxy, TLS termination, and backup automation, all of which a real production deployment needs layered on top.
Use docker compose exec wordpress wp if WP-CLI is installed inside your custom image, or docker compose run --rm --entrypoint wp wordpress:cli to run it as a one-off container using the official wp-cli image directly against your existing volumes.
Conclusion
A Docker-based setup trades a small amount of upfront YAML for an environment that's identical for every developer on a team, disposable when something goes wrong (docker compose down -v and start over), and easy to match precisely to a production host's PHP and MySQL versions. The docker-compose.yml in Step 2 is a complete, working starting point; everything past that (WP-CLI, Xdebug, database imports) is worth adding incrementally as an actual project needs it, rather than upfront.
Once local development is containerized, the natural next step is wiring the same environment definitions into a Git-based CI/CD pipeline, so the code that ran correctly in Docker locally deploys the same way to staging and production.
Here are a few additional resources if you want to go deeper:
- Docker Official Images: WordPress — the official image's full tag list and environment variable reference.
- Docker Compose File Reference — the complete syntax reference for docker-compose.yml.
- Docker Official Images: MySQL — the official MySQL image's configuration and environment variable reference.


