Oleksii Siniaiev
Post page navigation
Articles 7 min read March 5, 2023

Docker and WordPress: How Containers Can Simplify Your Development Process

Why Docker is a practical way to make WordPress environments reproducible, portable, and easier to maintain across local development, staging, and production.

On this page

TL;DR

  • Docker’s real win for WordPress is parity and onboarding: the same PHP version, extensions, and MySQL everyone runs, started with one command instead of a day of setup.
  • The traps are WordPress-specific, not Docker-specific: DB_HOST is the service name not localhost, WordPress stores absolute URLs in the database, and the container’s user ID fights your file permissions.
  • Bind-mount your code; never bind-mount vendor/, wp/, or node_modules; keep the database in a named volume so it survives docker compose down.
  • This covers the local development workflow. For the git-pull production side on shared hosting, see the Bedrock deploy guide linked at the end.

The “works on my machine” bug that finally pushed me to Docker for WordPress was a PHP extension. A plugin needed intl, it was present on my Mac and missing on a teammate’s, and we lost an afternoon to a white screen that only one of us could reproduce. That is the class of problem containers remove: not by magic, but by making the runtime a file in the repo instead of a property of someone’s laptop.

This article is the local development half of running WordPress in Docker — the parity, the workflow, and the WordPress-specific traps that generic Docker tutorials skip. The production deploy on shared hosting is a different problem with its own sharp edges, and it has its own guide at the end.

What Docker actually fixes in WordPress development

WordPress is unusually exposed to environment drift. It runs on a PHP version, a set of extensions, a MySQL version, and a web server, and a plugin can quietly depend on any of them. Containers pin all four in version-controlled config, so the stack is identical for everyone and reproducible in CI.

The concrete payoffs, in order of how much they matter day to day:

Onboarding

A new developer runs one command and has the full stack, instead of a README full of brew installs.

Version parity

Pin PHP and MySQL to match production, so “works locally” means something.

Disposable state

Break the database, drop the volume, reimport. No fear of corrupting a local install.

Plugin testing

Spin a throwaway stack to test a plugin or PHP upgrade without touching your real setup.

A development stack that is actually usable

The single-container demo everyone shows you is fine for a five-minute look and useless for real work:

Bash
docker run --name wp -p 80:80 -d wordpress:latest

It has no persistent database, no access to your theme and plugin code, and no way to run WP-CLI comfortably. Real development needs a multi-service stack with your code mounted in and the database persisted:

Code
services:
  app:
    image: wordpress:php8.3-apache
    ports:
      - "8880:80"
    volumes:
      - ./wp-content:/var/www/html/wp-content
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress
    depends_on:
      - db

  db:
    image: mysql:8.0
    environment:
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: wordpress
      MYSQL_ROOT_PASSWORD: root
    volumes:
      - db_data:/var/lib/mysql

volumes:
  db_data:

Two decisions in there matter more than they look. The PHP version is pinned in the image tag to match production — drift between local PHP 8.3 and a production 8.1 is exactly the bug Docker is supposed to prevent, so do not leave it floating on latest. And the database lives in the named volume db_data, which survives docker compose down; the code is a bind mount so your editor changes are live.

The WordPress-specific traps

These are the issues that are not in a generic Docker tutorial because they are WordPress problems that happen to surface inside Docker.

DB_HOST is the service name, not localhost

Inside the Compose network, containers find each other by service name. The database host is db, not localhost and not 127.0.0.1. Setting it to localhost makes WordPress try a socket that does not exist in the app container, and you get “Error establishing a database connection” with no further detail.

WordPress stores absolute URLs in the database. The siteurl and home options, and URLs baked into post content, are full http://localhost:8880 strings. Import a production database dump locally and every link, image, and redirect still points at production. Fix it with a serialization-safe search-replace, never a raw SQL UPDATE that corrupts serialized data:

Bash
docker compose exec app wp search-replace 'https://example.com' 'http://localhost:8880' --all-tables --skip-columns=guid

File ownership fights you. The Apache image runs as www-data (UID 33). Files it writes — uploads, generated CSS, plugin installs — end up owned by UID 33 on your host, and files you create as your host user may not be writable by the container. On Linux especially this shows up as upload failures. The clean fix is to align the container’s user with your host UID, or to keep writable directories (uploads) on a volume the container owns while your code stays a plain bind mount.

Run WP-CLI through the container. WP-CLI needs the same PHP and database the site uses, so run it inside the app container, not on your host:

Bash
docker compose exec app wp plugin list
docker compose exec app wp db export backup.sql

What not to mount

Bind mounts are how your code reaches the container, but mounting the wrong directories is the most common way to slow the stack down or break it outright.

Mounting vendor/ or wp/

Composer-managed directories should live in the image, not be overridden by host files. Mounting them invites version mismatches.

Mounting node_modules

Host and container architectures differ. Build assets inside the container or keep node_modules out of the mount.

Floating PHP version

Using wordpress:latest instead of a pinned PHP tag reintroduces the exact drift Docker is meant to remove.

Database in a bind mount

MySQL data on a host bind mount is slow and permission-prone. Use a named volume.

Committing uploads

wp-content/uploads is user data, not code. Keep it out of git and out of the image.

localhost for the DB host

Containers reach the database by service name. localhost points the app container at itself.

Optional services worth adding

Once the core stack works, a few extra services make local development noticeably better. A mail catcher like Mailpit captures outgoing email so password resets and notifications land in a web UI instead of the void. Redis gives you a real object cache that matches production behavior. A dedicated queue worker container lets you exercise jobs the way they actually run. Each is a few lines in the same Compose file, version-controlled alongside the rest.

FAQ

Why does WordPress show “Error establishing a database connection” in Docker?
Almost always the database host. Inside Docker Compose, services reach each other by service name, so DB_HOST must be the database service name (db), not localhost. A secondary cause is the app container starting before MySQL is ready; depends_on plus a retry on connection handles that.
Why are my image URLs wrong after importing a production database?
WordPress stores absolute URLs in the database. Run a serialization-safe wp search-replace from the production domain to your local URL across all tables, skipping the guid column. Never do this with a raw SQL UPDATE, which corrupts serialized option data.
Should I mount vendor/ and wp/ as volumes?
No. Composer-managed directories belong in the image so versions stay consistent. Bind-mount only your own code (themes, plugins, mu-plugins). Mounting managed directories is a common source of “it works in CI but not locally” mismatches.
How do I run WP-CLI in a Dockerized WordPress site?
Run it inside the app container with docker compose exec app wp <command> so it uses the same PHP version and database connection as the site. A Makefile wrapper around that command saves a lot of typing.

Updated: June 16, 2026

Share this article

LinkedIn X Email

Get in touch

Working on something similar? Let's talk.

I am always open to discussing architecture, Laravel, WordPress, performance, and practical implementation problems.

Send a message See selected work

Explore more

Articles

June 14, 2026

Part 3. A month with an AI journal: finding patterns in sleep, stress, and workouts

How to analyze an AI journal after the first month: transcription fixes, honest reflection…
Articles

June 14, 2026

Part 2. Hermes Agent + DeepSeek on Ubuntu: a complete Telegram AI journal setup

A step-by-step setup for Hermes Agent and DeepSeek on Ubuntu: a locked-down Telegram bot,…
Articles

June 14, 2026

Part 1. How I turned an old gaming laptop into an AI wellbeing journal

How an old Xiaomi Mi Gaming Laptop became a home AI server: Hermes Agent,…