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:
- Edit
wp-theme/style.cssor a PHP template locally. - Save the file.
- Refresh WordPress.
- 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.