# Wp Docker Compose Stack

> Build a custom docker-compose development stack for WordPress when wp-env's abstraction runs out — Redis object cache, Mailpit SMTP capture, custom PHP extensions (phpredis, Xdebug), php.ini overrides. Covers the official wordpress image's runtime-env config model (wp-config.php reads WORDPRESS_DB_* / WORDPRESS_CONFIG_EXTRA via getenv at request time, so every container sharing the volume — wp-cli included — needs the same environment, best shared via a YAML anchor), the mariadb healthcheck + service_healthy dependency, a wp-cli service as user 33:33, wiring the redis-cache plugin drop-in with WP_REDIS_HOST, why wp_mail silently fails (invalid wordpress@localhost From) and the phpmailer_init + Mailpit fix, pecl Dockerfiles for phpredis/Xdebug, and pinning image tags because wordpress:latest lags WP releases. Use when composing docker-compose.yml for WP, adding Redis/mail capture to local dev, or debugging a WP container where config env vars seem ignored.

- Skill: `lonsdale201/wp-docker-compose-stack` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add lonsdale201/wp-docker-compose-stack`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lonsdale201/wp-docker-compose-stack/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- License: GPLv2-or-later
- Author: Lonsdale201 (https://skillmd.com/u/lonsdale201)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/lonsdale201/wp-docker-compose-stack

---


# A custom docker-compose stack for WordPress

Hand-write a compose stack when you need services the official tooling doesn't model: Redis object cache, SMTP capture, custom PHP extensions, production-like topology. **Default to `wp-env` first** (see `wp-env-local-dev`) — it covers plain plugin/block development with less to maintain. This skill is the escape hatch, grounded against a fully booted and verified stack (WordPress + MariaDB + Redis + Mailpit + wp-cli, drop-in connected, mail captured). The complete tested files are bundled under `examples/`.

## When to use this skill

- Writing or reviewing a `docker-compose.yml` for WordPress development.
- Adding **Redis** (object cache), **Mailpit** (mail capture), **Xdebug**, or php.ini overrides to a WP container setup.
- Debugging "my `WORDPRESS_CONFIG_EXTRA` / `WP_REDIS_HOST` is ignored" or "wp-cli sees different config than the site".
- `wp_mail` silently fails inside a container.
- A team needs a production-like local topology that `wp-env` can't express.

## The config model: env is read at request time

This is the load-bearing fact of the official `wordpress` image, and the source of most broken stacks. The generated `wp-config.php` does **not** bake values in — it calls `getenv_docker()` on every request, and `WORDPRESS_CONFIG_EXTRA` is `eval`'d from the environment at runtime.

Consequence (verified by hitting it): a **wp-cli container sharing the same volume gets none of that config unless it has the same environment variables**. Our `wp redis enable` failed with `Connection refused [tcp://127.0.0.1:6379]` because only the `wordpress` service had `WORDPRESS_CONFIG_EXTRA` with `WP_REDIS_HOST` — the wp-cli service silently fell back to defaults. Share the block with a YAML anchor:

```yaml
x-wp-environment: &wp-environment
  WORDPRESS_DB_HOST: db
  WORDPRESS_DB_USER: wordpress
  WORDPRESS_DB_PASSWORD: wordpress
  WORDPRESS_DB_NAME: wordpress
  WORDPRESS_CONFIG_EXTRA: |
    define( 'WP_REDIS_HOST', 'redis' );
    if ( ! defined( 'WP_DEBUG' ) ) { define( 'WP_DEBUG', true ); }
    define( 'WP_DEBUG_LOG', true );

services:
  wordpress:
    environment: *wp-environment
  wpcli:
    environment: *wp-environment
```

Two refinements, both verified:

- **Guard `WP_DEBUG` with `if ( ! defined(...) )`** — wp-cli defines it first and an unguarded define in `CONFIG_EXTRA` throws "Constant WP_DEBUG already defined" warnings on every cli call. (Alternative: the image's own `WORDPRESS_DEBUG: 1` env var toggles `WP_DEBUG` without touching `CONFIG_EXTRA`.)
- Other image env vars: `WORDPRESS_TABLE_PREFIX`, the `WORDPRESS_AUTH_KEY`…`WORDPRESS_NONCE_SALT` salts (auto-generated if omitted), and a `_FILE` suffix convention on all of them for Docker secrets.

## Baseline stack

Abbreviated (full tested file: `examples/docker-compose.yml`):

```yaml
services:
  db:
    image: mariadb:lts
    environment:
      MARIADB_DATABASE: wordpress
      MARIADB_USER: wordpress
      MARIADB_PASSWORD: wordpress
      MARIADB_ROOT_PASSWORD: root
    volumes: [ db_data:/var/lib/mysql ]
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 5s
      timeout: 5s
      retries: 12

  wordpress:
    image: wordpress:latest        # pin in real projects — see below
    depends_on:
      db:
        condition: service_healthy
    ports: [ "8080:80" ]
    environment: *wp-environment
    volumes:
      - wp_data:/var/www/html
      - ./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini
```

- **The healthcheck is not optional.** WordPress crashes into a restart loop if it races a still-initializing MariaDB; `condition: service_healthy` with mariadb's bundled `healthcheck.sh` (verified) removes the race.
- **Pin the image.** `wordpress:latest` lags WordPress releases — at test time it shipped **WP 6.8.3 / PHP 8.3** while the current release was 7.0.1 (which `wp-env` did install). Use an explicit tag like `wordpress:6.8-php8.3-apache`, and update WP itself via `wp core update` in the volume when needed.
- **php.ini overrides** are a one-line mount into `/usr/local/etc/php/conf.d/` — verified `upload_max_filesize`/`memory_limit` took effect. See `examples/uploads.ini`.
- For plugin development, add a bind mount: `./:/var/www/html/wp-content/plugins/my-plugin` (on top of the named volume).

## wp-cli as a service

```yaml
  wpcli:
    image: wordpress:cli
    user: "33:33"
    profiles: ["cli"]              # doesn't start with `up`, only via `run`
    depends_on:
      db: { condition: service_healthy }
    environment: *wp-environment
    volumes: [ wp_data:/var/www/html ]
```

```bash
docker compose run --rm wpcli wp core install \
  --url=http://localhost:8080 --title="Dev" \
  --admin_user=admin --admin_password=admin \
  --admin_email=admin@example.com --skip-email
```

- `user: "33:33"` matters: the Alpine-based cli image defaults to uid 82, the Debian Apache image serves as uid 33 (`www-data`). Verified: with 33:33, files wp-cli writes (plugin installs, the Redis drop-in) come out owned by `www-data` and stay editable by the web container.
- The `Failed to create directory '/.wp-cli/cache/'` warning is benign (uid 33 has no writable HOME); silence it with `HOME: /tmp` in the environment if it bothers you.
- Wait for first boot before installing: the `wordpress` container generates `wp-config.php` on first run — until then wp-cli has nothing to load.

## Redis object cache (verified end-to-end)

```yaml
  redis:
    image: redis:alpine
```

With `WP_REDIS_HOST` pointing at the service name (in the shared anchor above):

```bash
docker compose run --rm wpcli wp plugin install redis-cache --activate
docker compose run --rm wpcli wp redis enable
docker compose run --rm wpcli wp redis status   # → Status: Connected, Drop-in: Valid, Ping: PONG
```

The official image ships **no phpredis extension** — verified: the drop-in falls back to the plugin's bundled **Predis** client, which works fine for dev. For phpredis (production parity), a two-line Dockerfile (verified build): `FROM wordpress:latest` + `RUN pecl install redis && docker-php-ext-enable redis`. For what the object cache changes about `wp_cache_*` semantics, see `wp-redis-object-cache`.

## Mail capture with Mailpit (and why wp_mail fails first)

```yaml
  mailpit:
    image: axllent/mailpit
    ports: [ "8025:8025" ]         # web UI + API; SMTP is 1025 inside the network
```

Two things must both be fixed — verified by hitting each failure separately:

1. **WordPress doesn't speak SMTP by default** (and the container has no sendmail): route PHPMailer to Mailpit.
2. **The default From address is invalid in Docker.** With a `localhost` site URL, WP builds `From: wordpress@localhost`, which PHPMailer rejects (`Invalid address`) — so `wp_mail()` returns `false` *before any SMTP attempt*, with no output unless you listen to `wp_mail_failed`.

Drop this as an mu-plugin (`wp-content/mu-plugins/mailpit.php`):

```php
<?php
add_action( 'phpmailer_init', function ( $phpmailer ) {
	$phpmailer->isSMTP();
	$phpmailer->Host = 'mailpit';
	$phpmailer->Port = 1025;
} );
add_filter( 'wp_mail_from', fn() => 'dev@example.test' );
```

Verified: with both in place the message lands in Mailpit (UI at <http://localhost:8025>, scriptable via `GET /api/v1/messages`). When debugging, hook `wp_mail_failed` and print the `WP_Error` — it names the real cause.

## Xdebug

The official image has no Xdebug; add it with a Dockerfile (verified build, Xdebug 3.5.3 — full file: `examples/Dockerfile.xdebug`):

```dockerfile
FROM wordpress:latest
RUN pecl install xdebug && docker-php-ext-enable xdebug
```

Configure via a conf.d ini: `xdebug.mode=debug`, `xdebug.start_with_request=trigger`, `xdebug.client_host=host.docker.internal`, `xdebug.client_port=9003`. `host.docker.internal` reaches the IDE on Docker Desktop; on native Linux add it via `extra_hosts: ["host.docker.internal:host-gateway"]`.

## Critical rules

- **Every container that loads WordPress needs the same `WORDPRESS_*` environment** — the image resolves config from env at request time, not at install time. Use a YAML anchor; a config that "works on the site but not in wp-cli" is this bug.
- **Healthcheck the DB and gate WordPress on `service_healthy`** — `depends_on` alone only orders startup, it doesn't wait for MySQL to accept connections.
- **Pin image tags.** `wordpress:latest` lags WP releases and silently changes PHP major versions under you.
- **Run wp-cli as `user: "33:33"`** so written files stay owned by `www-data`; alpine-cli's default uid 82 leaves root-unfriendly ownership mismatches.
- **`wp_mail` in a container needs a valid From address AND an SMTP route** — fix both (`wp_mail_from` + `phpmailer_init`), and debug via `wp_mail_failed`, never by staring at a silent `false`.
- **Guard constants in `CONFIG_EXTRA`** (`if ( ! defined(...) )`) — wp-cli pre-defines some (WP_DEBUG) and unguarded defines warn on every invocation.
- **Named volumes for `/var/www/html` and the DB** — `docker compose down` keeps them, `down -v` is the deliberate wipe.

## Cross-references

- Run **`wp-env-local-dev`** first — if the project needs no custom services, the official tool is less to maintain.
- Run **`wp-redis-object-cache`** for correct `wp_cache_*` usage and drop-in lifecycle once Redis is wired.
- Run **`wp-phpunit-test-setup`** for the test harness; its wp-env route already includes the WP test suite, no extra compose service needed.

## What this skill does NOT cover

- Production hosting, TLS, scaling, or hardening — this is a development stack.
- `wp-env` itself (see `wp-env-local-dev`) and WordPress Playground.
- Node build tooling (Tailwind, @wordpress/scripts): run watchers on the host — file-watching through container mounts is slow and gains nothing locally; containerize builds only in CI.

## References

- Official wordpress image docs (env vars, variants, `_FILE` secrets): <https://hub.docker.com/_/wordpress>
- mariadb image healthcheck.sh: <https://mariadb.com/kb/en/using-healthcheck-sh/>
- Mailpit: <https://mailpit.axllent.org/>
- Redis Object Cache plugin: <https://wordpress.org/plugins/redis-cache/>
- Bundled, boot-verified files: `examples/docker-compose.yml`, `examples/uploads.ini`, `examples/Dockerfile.xdebug`, `examples/mu-plugin-mailpit.php`

