Docker Mounts: Why Local Theme Edits Appear Inside WordPress

I edited a CSS file in a normal folder on my Mac, refreshed local WordPress, and the change appeared inside a Linux container.

No upload. No image rebuild. No file-copy command.

The explanation was not synchronization magic. WordPress and my editor were looking at the same mounted directory through two filesystem paths.

Container storage is not project storage

A container has a writable filesystem layer above its image. Changes made there normally survive a stop and start of that same container, but they are tied to the container. Remove and recreate it, and that writable layer is not a dependable place for application data.

Docker mounts place important data outside that layer.

For WordPress development, I used two kinds:

services:
  db:
    volumes:
      - db_data:/var/lib/mysql

  wordpress:
    volumes:
      - wp_data:/var/www/html
      - ./wp-theme:/var/www/html/wp-content/themes/my-child-theme

volumes:
  db_data:
  wp_data:

db_data and wp_data are named volumes. ./wp-theme is a bind mount.

Named volumes

A named volume is managed by Docker. The Compose file refers to it by name rather than by a normal host path:

- db_data:/var/lib/mysql

This is appropriate for database storage because:

  • Docker manages its location and lifecycle.
  • Linux containers see a native filesystem.
  • The data persists when containers are recreated.
  • I do not need to edit MariaDB’s internal files directly.

Named volumes are inspectable and back upable, but they are intentionally less convenient than an ordinary project directory.

Bind mounts

A bind mount maps a specific host path into the container:

- ./wp-theme:/var/www/html/wp-content/themes/my-child-theme

The left side is the project directory on the Mac. The right side is where WordPress expects the child theme inside the container.

When PHP opens:

/var/www/html/wp-content/themes/my-child-theme/functions.php

Docker presents the file from:

./wp-theme/functions.php

There is no separate deployment copy inside the container. That is why saving locally changes what WordPress reads on its next request.

Bind mounts are appropriate for:

  • Source code
  • Configuration under active development
  • Files that belong in Git
  • Anything I need to inspect and edit with host tools

They are less attractive for database internals because host/container permission differences and cross-platform filesystem translation can hurt reliability and performance.

The path on the right matters

WordPress identifies a theme by its directory and stylesheet metadata. If the database expects my-child-theme but the mount targets generatepress-child, WordPress sees a different theme.

The container path must match the application expectation:

- ./wp-theme:/var/www/html/wp-content/themes/my-child-theme

A useful diagnostic is:

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

If that directory is empty or missing, check:

  • Does ./wp-theme exist?
  • Did Compose start from the expected project?
  • Is the target path spelled correctly?
  • Was the container recreated after changing the mount configuration?

docker compose restart restarts an existing container. It does not necessarily apply a changed Compose definition. Recreate it with:

docker compose up -d --force-recreate

or bring the project down and up again.

A mount can hide image content

Mounts overlay the target path.

If an image already contains files at /some/path and a bind mount is attached there, the mounted directory hides those image files for the life of the container. They were not deleted; they are simply obscured.

This explains a common surprise: mounting an empty host directory over a populated container directory makes the container directory appear empty.

For my theme, that behavior was desirable because the host directory was the source of truth. It would be dangerous if done accidentally over all of wp-content.

Permissions

The container’s web server runs under a Linux user such as www-data. Host files belong to my macOS account. Docker Desktop mediates much of that mismatch, but Linux hosts expose it more directly.

For theme source, I generally wanted:

  • The host editor to own and modify files.
  • The container to read them.
  • WordPress not to rewrite theme source.

A read-only mount can enforce that:

- ./wp-theme:/var/www/html/wp-content/themes/my-child-theme:ro

For local workflows that genuinely require WordPress to write into the mount, ownership and permissions need deliberate handling. Recursively changing source files to the container’s numeric UID is not always the best answer, especially on a shared repository.

Verifying both sides

The quickest proof is to compare the same file:

# Host
shasum -a 256 ./wp-theme/style.css

# Container
docker exec my-wp \
  sha256sum /var/www/html/wp-content/themes/my-child-theme/style.css

Matching checksums confirm that both paths expose the same bytes.

To inspect the mount definition:

docker inspect my-wp

The Mounts section shows the source, destination, and mount type.

macOS performance

Docker Desktop runs Linux containers inside a virtualized environment. Accessing many small host files through a bind mount can be slower than accessing a named volume inside the Linux filesystem.

For a small WordPress child theme, the trade-off was worthwhile. For MariaDB, node_modules, or build caches, named volumes or container-native storage are often better.

The practical rule I took away was:

  • Bind mount what humans edit.
  • Use named volumes for service-managed state.
  • Do not let an individual container be the only owner of important data.

Why understanding this mattered

Before the local environment, theme development felt like moving files between worlds. The bind mount made the relationship explicit: one source directory, presented in two contexts.

That understanding made troubleshooting easier too. If WordPress did not show a change, I could ask whether the problem was:

  • The host file
  • The mount
  • The container path
  • WordPress caching
  • Browser caching

“Docker is not seeing my file” stopped being a mysterious category of failure. It became a short chain I could inspect one link at a time.