Type something to search...
How to Set Up Local WordPress Development with Docker?

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_on only controls startup order (starting db before wordpress), 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 across docker compose down and container recreation. Without them, every docker compose down (as opposed to docker compose stop) would wipe the database and any WordPress core files installed inside the container.
  • Bind-mounting only wp-content/themes and wp-content/plugins, rather than the entire WordPress installation, is deliberate: core files stay inside the wp_data volume, 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: 1 turns on WP_DEBUG for 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 8000 or 3306 is 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-data user can end up owned by a UID that doesn't match your host user, making them awkward to edit outside Docker. Running docker compose exec wordpress chown -R www-data:www-data /var/www/html/wp-content fixes ownership after the fact, or configure a matching UID via a build argument for a more permanent fix.
  • Nothing shows up after "cloning" wp-content from a live site. If you cp a full wp-content folder including uploads on top of the mounted volumes, make sure the mount paths in docker-compose.yml actually point at the folder you populated, a common mistake is bind-mounting ./wp-content in 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:

Tags :
Share :

Related Posts

Effortlessly Crafting Compelling WordPress Pages

Effortlessly Crafting Compelling WordPress Pages

As a website owner or content creator, having the ability to seamlessly add new pages to your WordPress site is crucial. Whether you're introducing a

Continue Reading
High Traffic Tips for WordPress Mastery 🚥

High Traffic Tips for WordPress Mastery 🚥

In our digital age, where online visibility is paramount, ensuring your WordPress site can handle surging traffic is crucial. Just like a finely-tune

Continue Reading
How Do I Change the WordPress Login URL?

How Do I Change the WordPress Login URL?

By default, every WordPress site's login page lives at the same predictable address: yoursite.com/wp-login.php (which also happens to redirect from

Continue Reading