Building a Local WordPress Workshop with Docker

Archive note, October 2025: This records the
local-development setup I built while working on SoCalNomad. Image
versions and commands have changed since then, but the architecture
remains the same.

Editing a WordPress theme directly on a production server works right
up until it does not. A missing semicolon, a bad path, or a CSS rule
with more reach than expected can turn a small change into an
outage.

I wanted a safer workshop: real WordPress, real content, and the
ability to edit theme files locally without uploading them after every
change. Docker provided that separation without requiring me to install
and maintain Apache, PHP, and MariaDB directly on my Mac.

The shape of the environment

The setup used two containers:

  • WordPress with Apache and PHP
  • MariaDB for posts, settings, users, and other site data

Docker Compose described both services and placed them on the same
private network. WordPress connected to the database by service name
rather than by a public address.

The important distinction was how the data was mounted:

  • MariaDB used a Docker-managed named volume.
  • My child theme used a bind mount from the project directory.

The named volume kept database files outside the lifecycle of an
individual container. The bind mount made my local theme directory
appear inside WordPress.

services:
  db:
    image: mariadb:11.2
    environment:
      MYSQL_DATABASE: wordpress_local
      MYSQL_USER: wpuser
      MYSQL_PASSWORD: local_only_password
      MYSQL_ROOT_PASSWORD: local_only_root_password
    volumes:
      - db_data:/var/lib/mysql

  wordpress:
    image: wordpress:6.4-apache
    ports:
      - "8080:80"
    environment:
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_NAME: wordpress_local
      WORDPRESS_DB_USER: wpuser
      WORDPRESS_DB_PASSWORD: local_only_password
    volumes:
      - wp_data:/var/www/html
      - ./wp-theme:/var/www/html/wp-content/themes/my-child-theme

volumes:
  db_data:
  wp_data:

Those credentials were deliberately local. They were never intended
for an exposed server or committed environment file.

Starting the workshop

Once Docker Desktop was running, the whole environment came up
with:

docker compose up -d
docker compose ps

WordPress was available at http://localhost:8080. The
initial installation was ordinary WordPress setup: site title, local
administrator, and search-engine visibility disabled.

The first useful verification was not the browser. It was confirming
that the theme mount existed inside the container:

docker exec my-wp \
  ls -la /var/www/html/wp-content/themes/my-child-theme

If the local files appeared there, I knew WordPress and my editor
were looking at the same directory.

The useful part: live theme
editing

The bind mount changed the daily workflow:

  1. Edit wp-theme/style.css or a PHP template locally.
  2. Save the file.
  3. Refresh WordPress.
  4. See the change.

There was no FTP step and no image rebuild. The container read the
mounted files on the next request.

That made experimentation cheap. I could change layout, break
something, inspect the error, and correct it without involving the
public site.

It also made source control natural. The theme lived in a normal
project directory rather than being trapped inside a container or copied
down from production.

Bringing production content
home

A blank WordPress installation is useful for proving that the stack
works, but it does not reveal how a theme behaves with real posts, long
titles, old metadata, plugins, and menus.

The next step was importing a production database into the local
MariaDB container:

mysqldump [production options] > wordpress.sql

docker exec -i my-wp-db \
  mariadb -u wpuser -plocal_only_password wordpress_local \
  < wordpress.sql

After importing, the production home and
siteurl values had to point to localhost. Today I would
prefer WP-CLI search-replace because it understands serialized WordPress
data:

wp search-replace \
  'https://example.com' \
  'http://localhost:8080' \
  --all-tables \
  --skip-columns=guid

At the time, I updated the primary options directly because those
were the values blocking local access. That was enough for the immediate
problem, but it was not a complete migration strategy.

Problems I actually hit

The port was already
occupied

If another service owned port 8080, Compose could not start
WordPress.

lsof -i :8080

Changing the host side of the mapping to 8081:80
resolved the conflict without changing the container.

The theme directory looked
empty

Relative bind-mount paths are resolved from the Compose project
directory. Starting Compose from the wrong place or misspelling
./wp-theme can produce an empty directory where WordPress
expects a theme.

The fix was to verify both sides:

ls -la ./wp-theme
docker inspect my-wp

Imported plugins
made local login impossible

The production database brought production security configuration
with it. Wordfence and two-factor authentication were doing their jobs
in an environment where their assumptions no longer applied. That became
a separate debugging story.

Cached CSS hid successful
changes

Sometimes the mount was fine and the browser was the liar. A hard
refresh or temporarily disabling the cache was enough.

What Docker did and did not
solve

Docker did not make local development identical to production. My Mac
still differed from the ISPConfig server in networking, filesystem
behavior, and infrastructure.

The goal was behavioral parity:

  • The same WordPress theme
  • Representative content and settings
  • Comparable PHP and database versions
  • A safe place to reproduce problems

That was enough to make theme work substantially less risky.

Containers also are not disposable in quite the way I described them
when first learning the workflow. Stopping a container does not erase
its writable layer. Removing and recreating it can. Persistent state
should still live in named volumes or bind mounts because a container
should never be the only copy of important data.

The daily rhythm

The resulting routine was simple:

docker compose up -d

# edit and test

docker compose down

docker compose down stopped and removed the containers
while preserving named volumes. When I returned, the WordPress database
was still there and the theme source had never left the project
directory.

The value was not Docker for its own sake. It was the separation
between experimentation and production. Once that boundary existed, I
could make changes with curiosity instead of caution.