Type something to search...
How to Run WordPress Locally with Docker Compose?

How to Run WordPress Locally with Docker Compose?

Every WordPress developer eventually hits the same wall with local environments. One project needs PHP 8.3, another is stuck on 7.4. One client runs MariaDB, another MySQL 8. A desktop app that worked last year breaks after an operating system update, and a teammate's setup never quite matches yours. Docker Compose solves this by describing the whole stack, web server, PHP, database, and tools, in a single file that lives with the project. Anyone who clones the repository runs one command and gets the same environment, and when the project is over, one more command removes it without leaving anything behind on your machine.

This article covers running WordPress locally with Docker Compose from scratch: the project layout, a complete compose.yaml using the official WordPress and MariaDB images, environment variables and secrets, persistent volumes, health checks, adding WP-CLI, phpMyAdmin, and Mailpit, mounting your own theme or plugin, tuning PHP settings, importing an existing site, and fixing the problems people run into most often.

Why Docker Compose for WordPress?

Docker runs each service in an isolated container built from an image. Docker Compose describes several containers, how they connect, and what data they keep, in one YAML file. For WordPress, that means:

  • The environment is code. PHP version, database version, and settings are written down and committed with the project.
  • Projects do not conflict. Each project gets its own containers, network, and database.
  • It is close to production. You run Apache or Nginx, real PHP, and real MySQL or MariaDB, not an emulation.
  • Cleanup is trivial. docker compose down -v removes everything for that project.
Local optionDatabaseConfig in repoClose to productionSetup effort
Desktop apps (MAMP, Local)MySQLRarelyMediumLow
WordPress PlaygroundSQLiteBlueprintLowVery low
wp-envMariaDB.wp-env.jsonMedium–highLow
Docker ComposeMySQL or MariaDBcompose.yamlHighMedium

If you mainly build plugins and blocks, wp-env wraps Docker with sensible WordPress defaults, as described in how to set up a local WordPress development environment with wp-env. Plain Docker Compose is the better fit when you want full control over every service, need to mirror a specific production stack, or are adding services wp-env does not provide.

Prerequisites

You need Docker with the Compose v2 plugin:

  • macOS and Windows: Docker Desktop, or an alternative such as OrbStack or Colima on macOS.
  • Linux: Docker Engine plus the docker-compose-plugin package.

Check that both are available:

# Terminal
docker --version
docker compose version

Compose v2 is invoked as docker compose with a space. The old standalone docker-compose binary is deprecated, and the version: key at the top of Compose files is obsolete and can be removed.

Project Structure

Create a folder for the project with this layout:

my-wp-site/
├── compose.yaml
├── .env
├── .gitignore
├── config/
│   └── php.ini
└── wp-content/
    ├── themes/
    └── plugins/

The wp-content folder is where your themes and plugins live on your machine. The rest of WordPress core stays inside the container, so you never commit or edit core files.

Environment Variables

Keep credentials out of compose.yaml. Compose automatically reads a file named .env in the project folder and substitutes its values into the configuration:

# .env
COMPOSE_PROJECT_NAME=mywpsite
WP_PORT=8080
DB_NAME=wordpress
DB_USER=wordpress
DB_PASSWORD=change-me-local-only
DB_ROOT_PASSWORD=change-me-root-local-only

COMPOSE_PROJECT_NAME prefixes container, network, and volume names, which keeps several projects apart. Add .env to .gitignore and commit a .env.example with placeholder values instead, so teammates know which variables to set.

The compose.yaml File

Here is a complete file for WordPress with MariaDB. It uses the official images, waits for the database to be healthy before starting WordPress, persists data in named volumes, and mounts your local wp-content:

# compose.yaml
services:
  db:
    image: mariadb:11.4
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: ${DB_NAME}
      MARIADB_USER: ${DB_USER}
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  wordpress:
    image: wordpress:php8.3-apache
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "${WP_PORT:-8080}:80"
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_NAME: ${DB_NAME}
      WORDPRESS_DB_USER: ${DB_USER}
      WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
      WORDPRESS_TABLE_PREFIX: wp_
      WORDPRESS_DEBUG: 1
      WORDPRESS_CONFIG_EXTRA: |
        define( 'WP_ENVIRONMENT_TYPE', 'local' );
        define( 'WP_DEBUG_LOG', true );
        define( 'WP_DEBUG_DISPLAY', false );
    volumes:
      - wp_core:/var/www/html
      - ./wp-content/themes:/var/www/html/wp-content/themes
      - ./wp-content/plugins:/var/www/html/wp-content/plugins
      - ./config/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro

volumes:
  db_data:
  wp_core:

What each part does:

  • image: mariadb:11.4 pins a long-term-support MariaDB release. Pinning versions keeps everyone on the same database. You can use mysql:8.4 instead; replace the MARIADB_* variables with the matching MYSQL_* ones and use a mysqladmin ping health check.
  • healthcheck uses the healthcheck.sh script that ships in the official MariaDB image. It reports healthy only when the server accepts connections and InnoDB has finished initializing.
  • depends_on with condition: service_healthy makes WordPress wait for that health check. Without it, WordPress can start first and show "Error establishing a database connection" on the first load.
  • image: wordpress:php8.3-apache chooses the PHP version through the image tag. Switch to php8.2-apache or php8.4-apache to test other versions.
  • WORDPRESS_DB_HOST: db uses the service name as the hostname. Compose puts both containers on a private network where service names resolve automatically.
  • WORDPRESS_DEBUG and WORDPRESS_CONFIG_EXTRA are read by the image's entry script to generate wp-config.php. The extra block is a good place for constants like WP_ENVIRONMENT_TYPE.
  • wp_core volume holds WordPress core, so it survives container restarts and recreation.
  • Bind mounts for themes and plugins map your local folders into the container. Edit files in your editor, refresh the browser, and the changes are live.

PHP Settings

The default PHP limits in the image are low for real work, especially uploads. Create config/php.ini:

; config/php.ini
upload_max_filesize = 128M
post_max_size = 128M
memory_limit = 512M
max_execution_time = 300
max_input_vars = 5000

The zz- prefix in the mount path makes the file load last, so its values override earlier configuration files in the same directory.

Starting WordPress

From the project folder, start the stack in the background:

# Terminal
docker compose up -d

The first run downloads the images, which takes a minute or two. Then check the status:

# Terminal
docker compose ps

When the db service shows healthy and wordpress is running, open http://localhost:8080 and complete the WordPress installer: choose a site title, admin username, password, and email. The database connection is already configured from the environment variables, so the installer skips that step.

Everyday Commands

# Terminal
docker compose up -d                  # start or update the stack
docker compose stop                   # stop containers, keep data
docker compose down                   # remove containers, keep volumes
docker compose down -v                # remove containers AND volumes (deletes the database)
docker compose logs -f wordpress      # follow WordPress and PHP logs
docker compose exec wordpress bash    # open a shell in the WordPress container
docker compose pull && docker compose up -d   # update to newer image versions

Be careful with down -v. It deletes the named volumes, which means your local database and WordPress core files are gone.

Adding WP-CLI

WP-CLI is the fastest way to manage a WordPress site, and the official wordpress:cli image provides it. Add a service that shares the WordPress files and database settings:

# compose.yaml (add under services:)
wpcli:
  image: wordpress:cli-php8.3
  user: "33:33"
  depends_on:
    db:
      condition: service_healthy
  environment:
    WORDPRESS_DB_HOST: db
    WORDPRESS_DB_NAME: ${DB_NAME}
    WORDPRESS_DB_USER: ${DB_USER}
    WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
  volumes:
    - wp_core:/var/www/html
    - ./wp-content/themes:/var/www/html/wp-content/themes
    - ./wp-content/plugins:/var/www/html/wp-content/plugins
  profiles:
    - tools

Two details matter here:

  • user: "33:33" runs WP-CLI as the same user ID as Apache's www-data in the WordPress image. The CLI image is based on Alpine Linux, where www-data has a different ID, so without this line files created by WP-CLI can end up with permissions Apache cannot write to.
  • profiles: [tools] keeps the service from starting with docker compose up. It only runs when you call it.

Run commands with docker compose run --rm:

# Terminal
docker compose run --rm wpcli wp core version
docker compose run --rm wpcli wp plugin install query-monitor --activate
docker compose run --rm wpcli wp user create editor editor@example.test --role=editor
docker compose run --rm wpcli wp rewrite structure '/%postname%/' --hard

You can also skip the installer entirely and set up the site from the command line:

# Terminal
docker compose run --rm wpcli wp core install \
  --url="http://localhost:8080" \
  --title="Local Site" \
  --admin_user=admin \
  --admin_password=password \
  --admin_email=admin@example.test \
  --skip-email

For a full tour of the commands available, see how to use WP-CLI to manage WordPress from the command line.

Adding phpMyAdmin and Mailpit

Two more services make local development much smoother. phpMyAdmin gives you a database GUI, and Mailpit catches every email WordPress sends so you can read password resets and order notifications without sending real mail.

# compose.yaml (add under services:)
phpmyadmin:
  image: phpmyadmin:5
  depends_on:
    db:
      condition: service_healthy
  ports:
    - "8081:80"
  environment:
    PMA_HOST: db
    UPLOAD_LIMIT: 128M

mailpit:
  image: axllent/mailpit:latest
  ports:
    - "8025:8025"

phpMyAdmin is now at http://localhost:8081; log in with the database user and password from .env. Mailpit's inbox is at http://localhost:8025.

WordPress still needs to be told to send mail through Mailpit's SMTP server on port 1025. Add a small must-use plugin. Must-use plugins load automatically and cannot be deactivated from the admin, which makes them a good fit for environment configuration:

<?php
// wp-content/mu-plugins/local-mailpit.php

/**
 * Plugin Name: Local Mailpit SMTP
 * Description: Sends all mail to Mailpit in local development.
 */

if ( 'local' !== wp_get_environment_type() ) {
return;
}

add_action( 'phpmailer_init', function ( $phpmailer ) {
$phpmailer->isSMTP();
$phpmailer->Host     = 'mailpit';
$phpmailer->Port     = 1025;
$phpmailer->SMTPAuth = false;
} );

Mount the mu-plugins folder in the WordPress service by adding one more volume line:

# compose.yaml (under the wordpress service volumes:)
- ./wp-content/mu-plugins:/var/www/html/wp-content/mu-plugins

The environment check means the file does nothing if it is ever copied to a server where WP_ENVIRONMENT_TYPE is not local.

Developing a Theme or Plugin

With the bind mounts in place, put your project code in the matching folder:

my-wp-site/wp-content/themes/my-theme/
my-wp-site/wp-content/plugins/my-plugin/

Activate it in the admin or with WP-CLI, and every save in your editor is reflected on the next page load. If you use a build step, for example @wordpress/scripts for blocks, run npm start on your machine in the plugin folder as usual; the container only needs the built files.

If your theme or plugin is its own Git repository, mount just that folder instead of all themes or plugins:

# compose.yaml (under the wordpress service volumes:)
- ../my-theme:/var/www/html/wp-content/themes/my-theme

Importing an Existing Site

To work on a copy of a live site, you need its database and its wp-content folder. Your regular backup is the safest source; see how to create a backup for a WordPress website.

  • Copy the site's themes, plugins, and uploads into your local wp-content folders. Add a bind mount for uploads if you need media locally.
  • Import the SQL dump into the database container:
# Terminal
docker compose exec -T db sh -c 'mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' < backup.sql
  • Replace the production URL with the local one, including serialized data, using WP-CLI:
# Terminal
docker compose run --rm wpcli wp search-replace 'https://www.example.com' 'http://localhost:8080' --skip-columns=guid --dry-run
docker compose run --rm wpcli wp search-replace 'https://www.example.com' 'http://localhost:8080' --skip-columns=guid

Run the dry run first to see how many replacements will happen. Never use a plain text find-and-replace on a WordPress SQL file, because it corrupts serialized PHP data when string lengths change.

  • Make sure the table prefix in your local WORDPRESS_TABLE_PREFIX matches the one used in the dump.

To export the local database later:

# Terminal
docker compose exec -T db sh -c 'mariadb-dump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' > local-backup.sql

Running HTTPS Locally

Some features, such as secure cookies, certain payment gateway sandboxes, and browser APIs, behave differently without HTTPS. The simplest approach is to put a reverse proxy in front of WordPress that terminates TLS with a locally trusted certificate, for example Caddy with its automatic local certificate authority, or certificates generated with mkcert. Then set WP_HOME and WP_SITEURL to the HTTPS address and tell WordPress it is behind a proxy:

// Added through WORDPRESS_CONFIG_EXTRA in compose.yaml
if ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && 'https' === $_SERVER['HTTP_X_FORWARDED_PROTO'] ) {
$_SERVER['HTTPS'] = 'on';
}

For most theme and plugin work, plain http://localhost is enough, so add HTTPS only when a feature requires it.

Common Problems and Fixes

  • "Error establishing a database connection" on first load. WordPress started before the database was ready, or the credentials differ. Use the health check with condition: service_healthy, and confirm the values in .env match both services. Changing .env after the first run does not change an existing database user; remove the db_data volume to start fresh.
  • Port 8080 is already in use. Another app or project uses it. Change WP_PORT in .env and restart.
  • Plugins cannot be installed or updated from the admin. File permissions in mounted folders do not match the container user. On Linux, make the folders writable by user ID 33, or run docker compose exec wordpress chown -R www-data:www-data /var/www/html/wp-content.
  • WP-CLI creates files Apache cannot write. Add user: "33:33" to the WP-CLI service.
  • Uploads fail with a file size error. The PHP limits are too low. Mount the custom php.ini and restart the stack.
  • The site redirects to the production URL after an import. The siteurl and home options were not replaced. Run wp search-replace again, or check for WP_HOME and WP_SITEURL constants.
  • Very slow page loads on macOS. Bind mounts of large folders are slow on some setups. Mount only the theme or plugin you are working on, and keep WordPress core in a named volume, as in the example.
  • Data disappeared. The stack was removed with docker compose down -v, which deletes volumes. Use down without -v for everyday use.

WordPress Docker Compose FAQ

Use whichever your production host runs, so you catch compatibility issues early. Both are supported by WordPress. MariaDB is a common default for local stacks, and its official image includes a ready-made health check script.

Change the tag of the WordPress image, for example from php8.3-apache to php8.2-apache, and use the matching CLI image tag. Then run docker compose up -d to recreate the container. Your database and wp-content are kept.

Yes. Use the wordpress fpm image tag, which runs PHP-FPM only, and add an Nginx service that serves static files and passes PHP requests to the WordPress container on port 9000. This mirrors many production hosts but adds an Nginx configuration file to maintain.

Not as written. It enables debugging, uses development passwords, and exposes phpMyAdmin and Mailpit. A production setup needs HTTPS, secrets management, backups, hardened settings, and usually a separate web server configuration.

Your themes and plugins live in the local wp-content folders you mounted. WordPress core lives in the wp_core named volume, and the database lives in the db_data volume. Docker manages volumes, and docker compose down -v deletes them.

Give each project its own folder, its own COMPOSE_PROJECT_NAME, and a different WP_PORT in its env file. Each stack gets separate containers, networks, and volumes, so they never share databases.

Conclusion

A Docker Compose setup turns your local WordPress environment into a few files that live with the project: compose.yaml for the services, .env for credentials, and php.ini for PHP settings. With the official WordPress and MariaDB images, a health check so WordPress waits for the database, named volumes for persistent data, and bind mounts for the code you are working on, you get an environment that is close to production and identical for everyone on the team.

From there, add the tools that make you faster: WP-CLI for scripting, phpMyAdmin for database inspection, and Mailpit to catch email safely. Pin your image versions, keep secrets out of the Compose file, and avoid down -v unless you really mean to start over.

Here are some useful references for going deeper on WordPress with Docker Compose:

  1. Docker Hub: Official WordPress image — tags, environment variables, and the CLI image.
  2. Docker Hub: Official MariaDB image — environment variables and the bundled health check script.
  3. Docker Docs: Compose file reference — services, volumes, health checks, and profiles.
  4. WP-CLI: Command reference — every command you can run in the WP-CLI container.
  5. Mailpit: Mailpit documentation — the local email testing tool and its SMTP settings.
Tags :
Share :

Related Posts

WordPress optimization with specific recommended approach

WordPress optimization with specific recommended approach

Whether you run a high traffic WordPress installation or a small blog on a low cost shared host, you should optimize WordPress and your server to run

Continue Reading
Creating and Customizing WordPress Child Themes

Creating and Customizing WordPress Child Themes

Creating a child theme in WordPress is a best practice for making modifications to a theme. By using a child theme, you can update the parent theme w

Continue Reading
Understanding the Distinction Categories vs. Tags in WordPress

Understanding the Distinction Categories vs. Tags in WordPress

WordPress, a powerful content management system, offers a plethora of features to organize content effectively. Among these features, categories and

Continue Reading