Setting up WordPress the old way meant installing PHP, a web server, and MySQL directly on your laptop, then praying the versions roughly matched whatever your host was running. They never did. A plugin that worked on your machine would throw a fatal error in production because the live server ran PHP 8.1 and you were on 8.4. Docker fixes that mismatch by packaging PHP, Apache, and the database into disposable containers you can spin up, tear down, and rebuild in seconds.

This guide covers the part most tutorials skip: running WordPress in Docker _locally_ as your development environment, with a real `docker-compose.yml` you can copy today. You'll get a working WordPress + database + phpMyAdmin stack, learn how to edit themes and plugins on a mounted volume, run WP-CLI inside a container, and — once your code lives in Git — hand the production step down to an automated deployment instead of Dockerizing your live site. By the end you'll have local-to-production parity without the works on my machine tax.

* * *

## Why run WordPress in Docker locally

Local WordPress development has three chronic problems, and containers solve all three.

**Environment parity.** Production runs a specific PHP version, a specific MySQL or MariaDB version, and a specific set of extensions. When your local machine drifts from those versions, bugs appear only after you deploy. Pinning the same image tags locally that your host runs closes that gap. If you understand [how Docker containers work](https://www.deployhq.com/blog/what-is-docker), you already know the pitch: the container _is_ the environment, so it behaves identically on your laptop, a teammate's laptop, and CI.

**No global pollution.** Installing PHP and MySQL system-wide means every project shares one runtime. Upgrade PHP for one client and you risk breaking another. Docker scopes everything to the project directory — delete the folder and the environment is gone, with nothing left behind in `/usr/local`.

**Disposable, reproducible setups.** A new developer clones the repo, runs one command, and has a working site in under a minute. No wiki page of setup steps that rot the moment someone bumps a version. The `docker-compose.yml` _is_ the documentation, and it's executable.

The catch — and this is the part to internalize before you start — is that WordPress in Docker locally and WordPress in Docker in production are different projects with different tradeoffs. This guide is firmly about the first. We'll cover why you probably shouldn't Dockerize production later on.

* * *

## A working docker-compose.yml for local WordPress

Here's a complete stack: WordPress (PHP + Apache), a MariaDB database, and phpMyAdmin for poking at tables. Create a project folder, drop this in as `docker-compose.yml`, and add a `.env` file alongside it.

```
# docker-compose.yml — local WordPress development stack
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
    ports:
      - "3306:3306"

  wordpress:
    image: wordpress:6.7-php8.3-apache
    restart: unless-stopped
    depends_on:
      - db
    environment:
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_NAME: ${DB_NAME}
      WORDPRESS_DB_USER: ${DB_USER}
      WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
      # WP_DEBUG on locally, never in production
      WORDPRESS_DEBUG: 1
    volumes:
      - wp_core:/var/www/html
      - ./wp-content/themes/my-theme:/var/www/html/wp-content/themes/my-theme
      - ./wp-content/plugins/my-plugin:/var/www/html/wp-content/plugins/my-plugin
    ports:
      - "8080:80"

  phpmyadmin:
    image: phpmyadmin:5.2
    restart: unless-stopped
    depends_on:
      - db
    environment:
      PMA_HOST: db
      UPLOAD_LIMIT: 64M
    ports:
      - "8081:80"

volumes:
  db_data:
  wp_core:
```

```
# .env — kept out of Git via .gitignore
DB_NAME=wordpress
DB_USER=wp_user
DB_PASSWORD=change-me-locally
DB_ROOT_PASSWORD=change-me-too
```

Start it:

```
# Pull images and launch in the background
docker compose up -d

# Watch the logs until WordPress finishes its first-run setup
docker compose logs -f wordpress
```

Open `http://localhost:8080` and you'll land on the WordPress installer. phpMyAdmin is at `http://localhost:8081`.

### What each service does

**`db` (MariaDB).** This is your database. MariaDB is a drop-in replacement for MySQL and is what many managed hosts actually run, so it's a sensible default. The `db_data` named volume persists your tables between restarts — without it, `docker compose down` would wipe your posts and settings every time. Note the environment variables use the `MARIADB_` prefix; if you swap in the `mysql:8.4` image instead, they become `MYSQL_DATABASE`, `MYSQL_USER`, and so on.

**`wordpress` (PHP + Apache).** The official `wordpress` image bundles PHP and Apache with WordPress core already installed. The tag `6.7-php8.3-apache` pins all three: WordPress 6.7, PHP 8.3, Apache. Pin these to match production — that's the entire point. `WORDPRESS_DB_HOST: db:3306` works because Compose puts every service on a shared network where the service name (`db`) resolves as a hostname.

**`phpmyadmin`.** Optional but handy — a browser UI for the database so you can inspect rows, run queries, and export dumps without a MySQL client. Drop this service entirely if you prefer the CLI.

### The volume strategy that actually matters

Notice we mount `wp_core` (a named volume) over the whole of `/var/www/html`, but then bind-mount only _your_ theme and plugin directories from the host on top of it. This is deliberate:

- WordPress core, and any plugins you install through the admin UI, live inside the named volume — you don't want those cluttering your Git repo.
- Your custom theme and plugin — the code you actually write and version — live on your host machine at `./wp-content/themes/my-theme` and `./wp-content/plugins/my-plugin`, so your editor sees them directly and changes appear instantly in the browser.

This split keeps your repository lean: it contains only the code you author, not the tens of thousands of core files.

* * *

## Working with themes and plugins on the mounted volume

Because your theme and plugin folders are bind-mounted, editing them is just editing files on your machine. Save `style.css`, refresh the browser, done — no rebuild, no copy step. That instant feedback loop is the day-to-day payoff of this setup.

Two gotchas bite almost everyone the first week:

**File permissions and UID mismatch.** Inside the container, Apache runs as the `www-data` user (UID 33 on Debian-based images). On Linux hosts, files you create as your own user (often UID 1000) can end up owned by a UID the container can't write to, and WordPress's media uploader will throw permission errors. The quickest local fix is to align ownership on the mounted directory:

```
# Give the container's www-data user write access to uploads
sudo chown -R 33:33 ./wp-content
```

On macOS and Windows this rarely bites because Docker Desktop's filesystem sharing translates UIDs for you. On native Linux it's the number-one cause of why can't I upload an image locally.

**Database persistence.** `docker compose down` stops and removes containers but keeps named volumes, so your database survives. `docker compose down -v` adds `-v` and deletes volumes too — that wipes your local database. Learn the difference now, because running the `-v` variant by reflex when you just wanted a clean restart is a classic way to lose an afternoon of test content.

### Running WP-CLI in a container

You don't install WP-CLI on your host — you run it in a throwaway container that shares the WordPress volumes and network. Add this to `docker-compose.yml`:

```
wpcli:
    image: wordpress:cli-php8.3
    depends_on:
      - db
      - wordpress
    environment:
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_NAME: ${DB_NAME}
      WORDPRESS_DB_USER: ${DB_USER}
      WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
    volumes:
      - wp_core:/var/www/html
    user: "33:33"
```

Then run commands with `docker compose run --rm`:

```
# Install WordPress non-interactively
docker compose run --rm wpcli wp core install \
  --url="http://localhost:8080" \
  --title="Local Dev Site" \
  --admin_user=admin \
  --admin_password=admin \
  --admin_email=you@example.com

# Activate your theme and a plugin
docker compose run --rm wpcli wp theme activate my-theme
docker compose run --rm wpcli wp plugin activate my-plugin

# Export the database to a file on your host
docker compose run --rm wpcli wp db export /var/www/html/backup.sql
```

The `--rm` flag deletes the CLI container after each command so they don't pile up. Scripting `wp core install` this way means a fresh clone can be fully bootstrapped — WordPress installed, theme active, sample content imported — with a single shell script, which is where the one command and you're running promise actually pays off.

* * *

## The bridge: from local Docker to production

Here's the mental model that keeps this simple. Docker is your _local development environment_. Your custom theme and plugin code is what you actually ship. Git is the pipeline between the two.

Commit the `wp-content/themes/my-theme` and `wp-content/plugins/my-plugin` directories (plus `docker-compose.yml`) to a repository. Do **not** commit `wp-config.php`, your `.env` file, WordPress core, or the `uploads` directory — those are environment-specific or reproducible, and secrets never belong in Git.

From there, you don't manually SFTP files to your server ever again. Once your theme and plugin code is in Git, you can [automate WordPress deployments from Git](https://www.deployhq.com/blog/automate-wordpress-deployments-with-git) so that every push to your main branch copies the changed files to your live WordPress host over SSH. This is the same [deployment automation](https://www.deployhq.com/blog/deployment-automation-a-quick-overview) pattern any modern web team uses: your local environment and your production release both trace back to the same commit.

The important boundary: [DeployHQ](https://www.deployhq.com) ships your _code_ to your host — it isn't a WordPress host and it doesn't run your containers in production for you. Your live site can be a VPS, a managed WordPress host, or shared hosting; [DeployHQ](https://www.deployhq.com) pushes the theme and plugin files there and runs any build steps you configure. If you're standing up the server side from scratch, our walkthrough on how to [install WordPress on a VPS](https://www.deployhq.com/blog/deploying-a-wordpress-application-on-a-vps-a-beginner-s-guide) covers the host setup, and since WordPress is PHP under the hood, the general approach to [deploy a PHP site](https://www.deployhq.com/blog/deploying-your-php-site-with-deployhq) applies directly.

Ready to wire your repository to your WordPress host? [Create a free](https://www.deployhq.com/signup)[DeployHQ](https://www.deployhq.com) account and connect your Git provider — start on the free plan or a 10-day trial and automate your first deployment.

If your theme has a build step — compiling Sass, bundling JavaScript, running Composer — put it in a [build pipeline](https://www.deployhq.com/features/build-pipelines) so the compiled assets are generated on DeployHQ's build servers and only the finished files land on production. Releases are applied as [atomic deployments](https://www.deployhq.com/features/atomic-deployments) (the new version is swapped in as a whole, so visitors never hit a half-updated site), and if a release misbehaves you get [one-click rollback](https://www.deployhq.com/features/one-click-rollback) to the previous version.

* * *

## Keeping local Docker and production in parity

The whole exercise is worthless if local and production quietly diverge. A few habits keep them honest:

**Pin the PHP version to production.** If your host runs PHP 8.2, use `wordpress:6.7-php8.2-apache` locally — not the `latest` tag, which floats. A plugin that relies on a function deprecated in PHP 8.3 will pass locally on 8.2 and fail on 8.3 (or vice versa). The image tag is your version contract.

**Match the database engine and major version.** If production runs MySQL 8, don't develop against MariaDB 11 and assume they're identical — they diverge on JSON functions, default collations, and some SQL modes. Set the local image to whatever your host actually runs.

**Never carry `WP_DEBUG` or dev credentials into production.** The `WORDPRESS_DEBUG: 1` line above is great locally and dangerous live. Configuration that differs between environments — debug flags, database credentials, API keys, salts — should come from environment variables on each host, never be baked into committed code. Keeping those inputs in sync (and separate) is exactly the discipline behind [keeping development and production in sync](https://www.deployhq.com/blog/keeping-development-staging-and-production-in-sync-with-deployhq).

**Why not just Dockerize production too?** It's tempting to run the same Compose stack live, but self-hosting a containerized WordPress in production means you now own database backups, TLS termination, security patching of the container host, scaling, and uptime — a real operations burden. For the vast majority of WordPress sites, a managed or VPS host plus automated Git deployment of your theme and plugin code delivers the parity benefit without the ops overhead. Keep Docker where it shines — local development — and let a purpose-built host run the live site. If you _do_ run containers in production, our guide on how to [deploy Docker containers to a VPS](https://www.deployhq.com/blog/docker-container-deployment-with-deployhq) covers that path.

* * *

## Frequently asked questions

**Do I need to commit WordPress core to Git?** No. Commit only the code you write — your custom theme and plugin directories, plus `docker-compose.yml`. WordPress core, `wp-config.php`, uploads, and your `.env` file should all be gitignored. Core is reproducible from the image; config and uploads are environment-specific.

**Why is my media upload failing with a permission error locally?** On Linux hosts this is almost always a UID mismatch between your host user and the container's `www-data` user (UID 33). Run `sudo chown -R 33:33 ./wp-content` on the mounted directory. macOS and Windows usually handle this automatically through Docker Desktop.

**How do I reset my local database without losing my code?** Run `docker compose down` (keeps volumes) to stop everything, then `docker compose up -d` to restart with data intact. Only `docker compose down -v` deletes the database volume. Your theme and plugin files live on your host, so they're never affected either way.

**Can I use MySQL instead of MariaDB?** Yes. Swap the `db` image for `mysql:8.4` and rename the environment variables from the `MARIADB_` prefix to `MYSQL_` (`MYSQL_DATABASE`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_ROOT_PASSWORD`). Match whatever your production host runs.

**Should I run this same Docker stack in production?** For most sites, no. Self-hosting containerized WordPress means owning backups, TLS, patching, and scaling. Keep Docker for local development and deploy your theme and plugin code to a managed or VPS host from Git instead.

* * *

## Wrapping up

Docker turns local WordPress development from a fragile, machine-specific chore into a reproducible environment that mirrors production. Pin your PHP and database versions to match your host, bind-mount only the theme and plugin code you author, and use WP-CLI in a container to script setup. Then let Git — not manual file transfers — carry that code to your live site.

Ready to automate the last step? Connect your repository and [deploy from GitHub to server](https://www.deployhq.com/deploy-from-github) — configure your host once, and every push ships your theme and plugin changes automatically.

* * *

Questions about deploying WordPress from Git? Email us at [support@deployhq.com](mailto:support@deployhq.com) or reach out on [X](https://x.com/deployhq).

