
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 -vremoves everything for that project.
| Local option | Database | Config in repo | Close to production | Setup effort |
|---|---|---|---|---|
| Desktop apps (MAMP, Local) | MySQL | Rarely | Medium | Low |
| WordPress Playground | SQLite | Blueprint | Low | Very low |
| wp-env | MariaDB | .wp-env.json | Medium–high | Low |
| Docker Compose | MySQL or MariaDB | compose.yaml | High | Medium |
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-pluginpackage.
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.4pins a long-term-support MariaDB release. Pinning versions keeps everyone on the same database. You can usemysql:8.4instead; replace theMARIADB_*variables with the matchingMYSQL_*ones and use amysqladmin pinghealth check.healthcheckuses thehealthcheck.shscript that ships in the official MariaDB image. It reports healthy only when the server accepts connections and InnoDB has finished initializing.depends_onwithcondition: service_healthymakes 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-apachechooses the PHP version through the image tag. Switch tophp8.2-apacheorphp8.4-apacheto test other versions.WORDPRESS_DB_HOST: dbuses the service name as the hostname. Compose puts both containers on a private network where service names resolve automatically.WORDPRESS_DEBUGandWORDPRESS_CONFIG_EXTRAare read by the image's entry script to generatewp-config.php. The extra block is a good place for constants likeWP_ENVIRONMENT_TYPE.wp_corevolume holds WordPress core, so it survives container restarts and recreation.- Bind mounts for
themesandpluginsmap 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'swww-datain the WordPress image. The CLI image is based on Alpine Linux, wherewww-datahas 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 withdocker 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-contentfolders. Add a bind mount foruploadsif 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_PREFIXmatches 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.envmatch both services. Changing.envafter the first run does not change an existing database user; remove thedb_datavolume to start fresh. - Port 8080 is already in use. Another app or project uses it. Change
WP_PORTin.envand 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.iniand restart the stack. - The site redirects to the production URL after an import. The
siteurlandhomeoptions were not replaced. Runwp search-replaceagain, or check forWP_HOMEandWP_SITEURLconstants. - 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. Usedownwithout-vfor 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:
- Docker Hub: Official WordPress image — tags, environment variables, and the CLI image.
- Docker Hub: Official MariaDB image — environment variables and the bundled health check script.
- Docker Docs: Compose file reference — services, volumes, health checks, and profiles.
- WP-CLI: Command reference — every command you can run in the WP-CLI container.
- Mailpit: Mailpit documentation — the local email testing tool and its SMTP settings.


