TechEarl

How to Run WordPress in Docker (With MySQL via Docker Compose)

WordPress and MySQL in one Compose file with TWO persistent volumes — one for the database, one for wp-content. Plus the common pitfalls (wp-content mount erasing the default theme, uploads permissions, port 80 in use).

Ishan KarunaratneIshan Karunaratne⏱️ 6 min readUpdated
Share thisCopied

WordPress and MySQL in Docker is the classic two-service Compose stack. Done right, it survives container removal, mounts wp-content cleanly so you can edit themes and plugins on the host, and connects via Compose service-name DNS. Done wrong, the first docker compose down wipes everything and the second time you mount wp-content it overwrites the default theme with an empty directory.

How do I run WordPress in Docker?

yaml
services:
  wordpress:
    image: wordpress:php8.4-apache
    ports:
      - "8080:80"
    environment:
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_USER: wp
      WORDPRESS_DB_PASSWORD: change-me
      WORDPRESS_DB_NAME: wordpress
    volumes:
      - wp-content:/var/www/html/wp-content
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: mysql:8.4
    environment:
      MYSQL_ROOT_PASSWORD: root-pass
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wp
      MYSQL_PASSWORD: change-me
    volumes:
      - db-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-proot-pass"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped

volumes:
  wp-content:
  db-data:

Save as docker-compose.yml, run docker compose up -d, visit http://localhost:8080, walk through the install wizard. Both services persist via named volumes; both restart automatically; Compose waits for MySQL to be healthy before starting WordPress.

Try it with your own values

Configure ports and credentials. Compose values update inline above.

The two volumes that matter

db-data on /var/lib/mysql — the MySQL data directory. Without this, the next docker compose down removes the container and your entire WordPress database goes with it. With it, you can stop and start the stack as many times as you want; the database persists.

wp-content on /var/www/html/wp-content — WordPress's themes, plugins, and uploads. This is the directory you actually care about preserving — the rest of WordPress core can be re-installed; wp-content is your customization. The image's default wp-content is copied into the named volume on first start, so default themes and plugins are still there.

A third option people sometimes do: mount the whole /var/www/html (the full WordPress install). That's wasteful — WordPress core changes only on version upgrades, and mounting all of it makes upgrades clunky. Mount wp-content only.

Bind mount wp-content for active development

When you're developing a theme or plugin, mount your local source over wp-content/themes/mytheme or wp-content/plugins/myplugin:

yaml
services:
  wordpress:
    # ...
    volumes:
      - wp-content:/var/www/html/wp-content
      - ./mytheme:/var/www/html/wp-content/themes/mytheme

Edit files on the host, refresh the browser, see changes. The named wp-content volume still holds the rest (other themes, uploads, plugins) and persists across container removal.

Practical usage: opening the admin and uploading a file

After docker compose up -d:

bash
# Tail logs to see when WordPress is ready
docker compose logs -f wordpress

# Once it's running, the install wizard is at http://localhost:8080

After the install:

  • Admin: http://localhost::host_port/wp-admin
  • Uploads end up in the wp-content volume; they persist across container restarts and host reboots.
  • Database access from the host: the db service isn't published by default (no ports: block on it). Add ports: ["3307:3306"] to the db service if you want to connect with a host-side MySQL client. Or use Adminer in a third container.

Run wp-cli inside the container

The official WordPress image doesn't ship wp-cli, but it's trivial to add:

bash
docker compose exec wordpress \
  bash -c 'curl -sO https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar && \
  chmod +x wp-cli.phar && mv wp-cli.phar /usr/local/bin/wp'

# Then any wp command:
docker compose exec -u www-data wordpress wp plugin list
docker compose exec -u www-data wordpress wp user list

-u www-data runs wp-cli as the web-server user so files it creates have the right ownership.

For a one-shot wp-cli run, the dedicated image wordpress:cli is cleaner:

yaml
services:
  cli:
    image: wordpress:cli
    user: "33:33"   # www-data UID
    volumes:
      - wp-content:/var/www/html/wp-content
    depends_on:
      - db
    profiles: ["cli"]   # only starts when explicitly requested
bash
docker compose run --rm cli wp plugin list

Backups

WordPress in Docker has two things to back up: the database and wp-content.

bash
# Database dump
docker compose exec db \
  mysqldump -u root -proot-pass --single-transaction wordpress \
  > backup-db-$(date +%Y%m%d).sql

# wp-content as a tarball
docker run --rm \
  -v wordpress_wp-content:/source:ro \
  -v "$(pwd):/backup" \
  alpine tar czf /backup/wp-content-$(date +%Y%m%d).tar.gz -C /source .

(The named volume's full name is <projectname>_wp-content where projectname is the Compose project name, usually the directory name. docker volume ls shows the actual name.)

Common pitfalls

  • Mounting wp-content and finding the default theme missing. The image's default wp-content is copied into the volume only on first start. If the volume already exists and is empty, no copy happens. Either delete the empty volume and let it re-init, or copy the default theme in manually with docker cp.
  • WORDPRESS_DB_HOST set to localhost. Inside the WordPress container, localhost is the WordPress container's loopback, not MySQL's. Use the service name: db:3306.
  • No healthcheck on the DB, no condition: service_healthy. WordPress starts before MySQL is ready and the install wizard errors out. Add the healthcheck and the depends_on: condition shown in the example.
  • Uploads owned by root. Without a writable wp-content volume, or with the wrong user mapping, the wizard can't write to /var/www/html/wp-content/uploads. The default image runs Apache as www-data (UID 33); permissions on the named volume are set correctly automatically. Bind mounts to host paths are the common source of UID mismatch.
  • Port 80 already in use. Pick a different host port (8080:80).
  • Memory limit too low. The default WORDPRESS_CONFIG_EXTRA doesn't set WP_MEMORY_LIMIT; complex sites may need bumping. Add WORDPRESS_CONFIG_EXTRA: "define('WP_MEMORY_LIMIT', '256M');" to the env.

What to do next

FAQ

Sources

Authoritative references this article was fact-checked against.

TagsDockerWordPressMySQLDocker ComposePersistenceDevOps

Found this useful? Pass it on.

Copied
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts