islammdshariful
islammdshariful
QA Engineer. I write about Playwright, automation, and AI-driven QA workflows.

How I Built a Multi-Site Docker Testing Environment for WordPress Plugin Testing

Testing a plugin across multiple configurations — Lite vs Pro, fresh installs vs existing sites — is painful without a reproducible environment. I built a Docker-based multi-site testing setup that spins up four isolated instances, manages plugin deployment, imports pre-configured databases, and integrates with Playwright for end-to-end testing.

In this guide, I’ll walk you through every piece of the system so you can adapt it for your own projects.

Table of Contents

  1. The Problem: Why You Need This
  2. Architecture Overview
  3. Project Structure
  4. Docker Compose: Defining the Multi-Site Stack
  5. The Override File: Local vs CI Environments
  6. The Management Script: Automating Everything
  7. MU-Plugins: Custom REST API Helpers for Testing
  8. Environment Configuration
  9. Integrating with Playwright for E2E Tests
  10. Running the Setup Step by Step
  11. CI/CD Integration with Buildkite
  12. Troubleshooting Common Issues

The Problem: Why You Need This

If you develop plugins, you know the testing challenge:

  • Lite vs Pro versions behave differently and load different code paths.
  • Fresh installs need to work flawlessly, but so do upgrades from older versions with existing data.
  • Manual testing on a single local application site cannot cover these scenarios simultaneously.
  • Your CI pipeline needs the exact same environment your local machine uses.

I needed a solution that could:

  • Run four application sites simultaneously (Lite New, Lite Old, Pro New, Pro Old).
  • Each site gets its own database, its own port, and only the plugins it should have.
  • Support database imports for “old” sites to simulate upgrades.
  • Work identically on local machines and CI servers.
  • Be manageable through simple CLI commands.

Architecture Overview

Here’s what the final architecture looks like:

                    ┌─────────────────────────────────────────┐
                    │            manage.js (Bun)               │
                    │   CLI for init/start/stop/reset/clean    │
                    └────────────────┬────────────────────────┘

                    ┌────────────────▼────────────────────────┐
                    │         docker-compose.yml               │
                    │    + docker-compose.override.yml (local) │
                    └────────────────┬────────────────────────┘

        ┌────────────┬───────────────┼───────────────┬────────────┐
        │            │               │               │            │
   ┌────▼────┐  ┌────▼────┐   ┌─────▼────┐   ┌─────▼────┐  ┌────▼────┐
   │Lite-New │  │Lite-Old │   │ Pro-New  │   │ Pro-Old  │  │phpMyAdmin│
   │ :8083   │  │ :8082   │   │  :8081   │   │  :8080   │  │  :8084   │
   │ WP+DB   │  │ WP+DB   │   │  WP+DB   │   │  WP+DB   │  │  (tool)  │
   │ +WP-CLI │  │ +WP-CLI │   │  +WP-CLI │   │  +WP-CLI │  └──────────┘
   └─────────┘  └─────────┘   └──────────┘   └──────────┘

Each site is a trio of containers:

  1. MariaDB — the database.
  2. application — the PHP/Apache web server.
  3. WP-CLI — a utility container for running application CLI commands.

Project Structure

_e2e-tests/
├── docker/
│   ├── docker-compose.yml              # Main service definitions
│   ├── docker-compose.override.yml     # Local dev volume mounts
│   ├── manage.js                       # Bun-powered management CLI
│   ├── conf/
│   │   └── uploads.ini                 # PHP upload configuration
│   ├── data/
│   │   └── pro-old.sql                 # Database dump for "old" sites
│   ├── mu-plugins/
│   │   └── test-automation-helpers.php # REST API helpers for testing
│   └── plugins/                        # Downloaded plugin builds
│       ├── my-plugin/
│       ├── my-plugin-pro/
│       ├── plugin-module-a/
│       ├── plugin-module-b/
│       └── ... (more addons)
├── tests/
│   └── e2e/                            # Playwright test files
├── playwright.config.js
├── package.json
├── .env                                # Environment configuration
└── run-tests.js                        # Test runner wrapper

Docker Compose: Defining the Multi-Site Stack

The docker-compose.yml file is the backbone of the setup. Each site follows the same pattern: a MariaDB database, a application container, and a WP-CLI utility container. Here’s the key design decisions:

Using Docker Compose Profiles

Instead of starting all four sites every time, I use Docker Compose profiles to selectively start only what you need:

services:
  db-lite-new:
    image: mariadb:latest
    environment:
      MYSQL_DATABASE: wordpress_lite_new
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: wordpress
      MYSQL_ROOT_PASSWORD: somewordpress
    volumes:
      - db_data_lite_new:/var/lib/mysql
    command: --default-authentication-plugin=mysql_native_password
    cap_add:
      - SYS_NICE
    profiles:
      - lite-new
      - all

The profiles field means this container only starts when you explicitly request the lite-new or all profile. This keeps resource usage low during development when you only need one or two sites.

application Container Configuration

Each application container maps to a unique host port and connects to its own database:

  wordpress-lite-new:
    image: wordpress:latest
    ports:
      - "${LITE_NEW_PORT:-8083}:80"
    environment:
      WORDPRESS_DB_HOST: db-lite-new
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress
      WORDPRESS_DB_NAME: wordpress_lite_new
      PHP_MEMORY_LIMIT: 512M
      PHP_OPCACHE_VALIDATE_TIMESTAMPS: 0
    volumes:
      - wordpress_data_lite_new:/var/www/html:cached
      - ./data:/data/:cached
      - ./conf/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini
    depends_on:
      - db-lite-new
    profiles:
      - lite-new
      - all

Key points:

  • Dynamic ports via environment variables (${LITE_NEW_PORT:-8083}) with sensible defaults.
  • Shared /data volume lets you mount SQL dumps accessible from inside every container.
  • Custom uploads.ini increases PHP upload limits for testing large file uploads:
file_uploads = On
upload_max_filesize = 128M
post_max_size = 128M

WP-CLI Sidecar Containers

Each site also gets a WP-CLI container for automated application administration:

  wpcli-lite-new:
    image: wordpress:cli
    entrypoint: ["wp", "--allow-root"]
    environment:
      WORDPRESS_DB_HOST: db-lite-new
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress
      WORDPRESS_DB_NAME: wordpress_lite_new
    volumes:
      - wordpress_data_lite_new:/var/www/html:cached
      - ./data:/data/:cached
    depends_on:
      - wordpress-lite-new
      - db-lite-new
    profiles:
      - lite-new
      - all

This container shares the same application volume, so WP-CLI operates on the same filesystem as the running application site. The entrypoint is set to wp --allow-root so every docker compose run command becomes a WP-CLI command automatically.

phpMyAdmin for Database Inspection

A bonus service for debugging database issues:

  phpmyadmin:
    image: phpmyadmin/phpmyadmin:latest
    ports:
      - "8084:80"
    environment:
      PMA_HOSTS: db-lite-new,db-lite-old,db-pro-new,db-pro-old
    profiles:
      - tools
      - all

It connects to all four databases through the PMA_HOSTS variable, so you can switch between them from a single phpMyAdmin interface.

Named Volumes

All data is persisted in Docker named volumes:

volumes:
  wordpress_data_lite_new:
  db_data_lite_new:
  wordpress_data_lite_old:
  db_data_lite_old:
  wordpress_data_pro_new:
  db_data_pro_new:
  wordpress_data_pro_old:
  db_data_pro_old:

This means stopping and restarting containers preserves your application installations and databases. Use bun run site:cleanup to wipe everything and start fresh.


The Override File: Local vs CI Environments

This is one of the most important design decisions. The docker-compose.override.yml file is automatically merged by Docker Compose when running locally, but explicitly excluded in CI.

What the Override File Does

For local development, it bind-mounts your actual plugin source code into the containers:

services:
  # Lite sites: Mount only the free plugin
  wordpress-lite-new:
    volumes:
      - ./plugins/my-plugin:/var/www/html/wp-content/plugins/my-plugin:delegated
      - ./mu-plugins:/var/www/html/wp-content/mu-plugins:delegated

  # Pro sites: Mount all pro plugins individually
  wordpress-pro-new:
    volumes:
      - ./plugins/my-plugin:/var/www/html/wp-content/plugins/my-plugin:delegated
      - ./plugins/my-plugin-pro:/var/www/html/wp-content/plugins/my-plugin-pro:delegated
      - ./plugins/plugin-module-a:/var/www/html/wp-content/plugins/plugin-module-a:delegated
      - ./plugins/plugin-module-b:/var/www/html/wp-content/plugins/plugin-module-b:delegated
      # ... more addon plugins
      - ./mu-plugins:/var/www/html/wp-content/mu-plugins:delegated

Why This Matters

ScenarioPlugin Delivery MethodOverride File Used?
Local devBind mounts (instant changes)Yes (auto-merged)
CI/CDdocker cp into containersNo (explicitly excluded)

Benefits for local development:

  • Instant file changes: Edit a plugin file and the change is immediately reflected inside the container.
  • No sync step needed: No need to rebuild or restart containers after code changes.
  • Lite sites only get Lite plugins: The override file enforces the same plugin separation that exists in production.

In CI, the manage.js script detects the CI environment and skips the override file:

const isCI = process.env.CI === 'true' || process.env.BUILDKITE === 'true';

if (isCI) {
  baseArgs.push('-f', join(SCRIPT_DIR, 'docker-compose.yml'));
} else {
  baseArgs.push('-f', join(SCRIPT_DIR, 'docker-compose.yml'));
  const overridePath = join(SCRIPT_DIR, 'docker-compose.override.yml');
  if (existsSync(overridePath)) {
    baseArgs.push('-f', overridePath);
  }
}

The Management Script: Automating Everything

The manage.js file is a Bun-powered CLI that orchestrates the entire lifecycle. Here’s what each command does:

Available Commands

Commandnpm ScriptDescription
initbun run site:initDownload plugins, start containers, install application, import databases, activate plugins
startbun run site:startStart existing containers (no setup)
stopbun run site:stopStop all running containers
resetbun run site:resetClean everything and re-initialize
cleanupbun run site:cleanupRemove all containers, volumes, and data
logsbun run site:logsTail container logs
download-pluginsbun run plugin:downloadDownload plugin builds from remote server
sync-pluginsbun run plugin:syncCopy plugins to running containers
wpRun WP-CLI commands on a specific site
export-dbbun run db:exportExport a site’s database to a SQL file
db uibun run db:phpmyadminStart phpMyAdmin

The Init Flow

The init command is the most complex. Here’s what happens step by step:

1. Load .env configuration
2. Check if plugins exist locally
   ├── Yes → Continue
   └── No → Download from remote build server (manifest.json)
3. Determine which sites to start (from SITES_TO_INIT)
4. Build Docker Compose profile arguments
5. Start containers with `docker compose up -d`
6. Wait 20 seconds for services to stabilize
7. Verify container health
8. Wait for databases to accept connections (retry loop)
9. For each site:
   ├── Copy plugins (CI) or use bind mounts (local)
   ├── Copy mu-plugins
   ├── Check if application is already installed
   │   ├── Yes → Update URLs, activate plugins
   │   └── No → Install application
   │       ├── "New" site → Set permalink structure
   │       └── "Old" site → Import SQL dump, update URLs
   └── Activate required plugins
10. Print summary with URLs and credentials

Plugin Download System

Plugins are downloaded from a remote build server using a manifest:

async function downloadPluginsFromRemote(branch = 'develop') {
  const baseUrl = `https://releases.example.com/.../releases/${branch}`;

  // Fetch manifest listing all plugin ZIP URLs
  const manifest = await fetch(`${baseUrl}/manifest.json`);
  const pluginUrls = JSON.parse(manifest);

  // Download and extract each plugin
  for (const downloadUrl of pluginUrls) {
    await $`curl -f -L -o ${tempZipPath} ${downloadUrl}`;
    await $`unzip -q -o ${tempZipPath} -d ${pluginsPath}`;
  }
}

This means you can test any branch’s plugin build by running:

bun run plugin:download -- feature-branch

Site Configuration Logic

The script knows which plugins belong to which site type:

const SITE_PLUGINS = {
  'lite-new': ['my-plugin'],
  'lite-old': ['my-plugin'],
  'pro-new': REQUIRED_PLUGINS,  // All 10 plugins
  'pro-old': REQUIRED_PLUGINS,
};

And which sites need database imports:

function getSiteConfigs() {
  return {
    'lite-new': { port: 8083, profile: 'lite-new', plugin: 'my-plugin' },
    'lite-old': { port: 8082, profile: 'lite-old', plugin: 'my-plugin', importDb: true },
    'pro-new':  { port: 8081, profile: 'pro-new',  plugin: 'my-plugin-pro' },
    'pro-old':  { port: 8080, profile: 'pro-old',  plugin: 'my-plugin-pro', importDb: true },
  };
}

Sites with importDb: true (the “old” sites) will have a pre-existing database imported after application installation, simulating a site that has been running with the plugin for a while.


MU-Plugins: Custom REST API Helpers for Testing

The test-automation-helpers.php mu-plugin exposes application internals via REST API endpoints that Playwright tests can query. This is a critical piece — it lets your tests read application state without going through the UI.

Exposed Endpoints

EndpointWhat It Returns
GET /wp-json/ (modified)application version in the generator field
GET /wp-json/wp/v2/posts (modified)Includes comment_count field on each post
GET /wp-json/e2e/v1/permalink-structureCurrent permalink structure setting
GET /wp-json/e2e/v1/timezoneapplication timezone setting
GET /wp-json/e2e/v1/date-formatDate format string
GET /wp-json/e2e/v1/time-formatTime format string
GET /wp-json/e2e/v1/pluginsAll installed plugins with status and version
GET /wp-json/e2e/v1/post-typesAll public post types
GET /wp-json/e2e/v1/taxonomiesAll public taxonomies
GET /wp-json/e2e/v1/reading-settingsAll reading settings (homepage display, posts per page, etc.)
GET /wp-json/e2e/v1/homepage-last-modifiedISO 8601 date of most recent content modification

Example: Plugin Status Endpoint

register_rest_route('e2e/v1', '/plugins', [
    'methods'  => 'GET',
    'callback' => function () {
        $plugins = get_plugins();
        $plugin_map = [];

        foreach ($plugins as $file => $data) {
            $slug = sanitize_title($data['Name']);
            $plugin_map[$slug] = [
                'file'    => $file,
                'status'  => is_plugin_active($file) ? 'active' : 'inactive',
                'version' => $data['Version'] ?? 'unknown',
            ];
        }

        return $plugin_map;
    },
    'permission_callback' => '__return_true',
]);

In a Playwright test, you can now verify plugin state programmatically:

const response = await request.get('http://localhost:8080/wp-json/e2e/v1/plugins');
const plugins = await response.json();
expect(plugins['my-plugin-pro'].status).toBe('active');

This is a mu-plugin (must-use plugin), meaning application loads it automatically without activation. It gets mounted into every container via the override file or copied via docker cp in CI.


Environment Configuration

The .env file drives the entire setup:

# application version
WP_VERSION=latest

# Default credentials
USERNAME=admin
PASSWORD=1234

# Which sites to initialize (comma-separated)
SITES_TO_INIT=pro-old,lite-new

# Site URLs (ports are extracted automatically)
PRO_OLD_BASE_URL=http://localhost:8080
PRO_NEW_BASE_URL=http://localhost:8081
LITE_OLD_BASE_URL=http://localhost:8082
LITE_NEW_BASE_URL=http://localhost:8083

How Ports Are Derived

Instead of duplicating port configuration, the manage.js script extracts ports from the URLs automatically:

function extractPortFromUrl(url) {
  const match = url.match(/:(\d+)/);
  return match ? parseInt(match[1], 10) : 80;
}

function exportPortsFromBaseUrls() {
  if (process.env.PRO_OLD_BASE_URL) {
    process.env.PRO_OLD_PORT = extractPortFromUrl(process.env.PRO_OLD_BASE_URL).toString();
  }
  // ... same for other sites
}

Docker Compose then uses these as variables:

ports:
  - "${PRO_OLD_PORT:-8080}:80"

One source of truth. Change the URL, and the port follows.


Integrating with Playwright for E2E Tests

The Playwright configuration reads the same .env file and targets the Docker-hosted application sites:

// playwright.config.js
import dotenv from 'dotenv';
dotenv.config();

export default defineConfig({
  testDir: './tests/e2e/',
  timeout: 80000,
  use: {
    headless: true,
    trace: 'retain-on-failure',
    actionTimeout: 10000,
    launchOptions: {
      slowMo: 50,
    }
  },
  projects: [
    {
      name: 'license-activation',
      testMatch: '**/general-settings.spec.js',
      grep: /Activate license key/,
      use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 798 } },
    },
    {
      name: 'visual-regression',
      testMatch: '**/visual-regression.spec.js',
      use: { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 940 } },
    },
    // ... more test projects
  ],
});

Running Tests

# Setup Playwright browsers (first time only)
bun run test:setup

# Run all tests
bun run test

# Run specific test file
bun run test:file tests/e2e/sitemap.spec.js

# Run tests by tag
bun run test:tag @smoke

# Run with visible browser (debug mode)
bun run test:debug

# View the HTML report
bun run test:report

Running the Setup Step by Step

Prerequisites

  1. Docker Desktop installed and running.
  2. Bun runtime installed (curl -fsSL https://bun.sh/install | bash).
  3. Node.js (for Playwright).

First-Time Setup

# 1. Clone the repository and navigate to the test directory
cd _e2e-tests

# 2. Install dependencies
bun install

# 3. Create your .env file
cp .env.example .env
# Edit .env with your configuration

# 4. Initialize the Docker environment
# This downloads plugins, starts containers, installs application,
# imports databases, and activates plugins
bun run site:init

# 5. Install Playwright browsers
bun run test:setup

# 6. Run your tests
bun run test

Daily Workflow

# Start your sites (if stopped)
bun run site:start

# Run tests
bun run test

# Check container status
bun run site:status

# Stop when done
bun run site:stop

When Things Go Wrong

# Full reset (wipes everything and re-initializes)
bun run site:reset

# Just clean up (remove all containers and volumes)
bun run site:cleanup

# Check container logs
bun run site:logs

# Open phpMyAdmin to inspect databases
bun run db:phpmyadmin
# Visit http://localhost:8084 (root / somewordpress)

# Run WP-CLI commands directly
docker/manage.js wp pro-old plugin list
docker/manage.js wp lite-new option get siteurl

CI/CD Integration with Buildkite

The setup works seamlessly in CI. The key differences:

  1. No override file — plugins are copied via docker cp instead of bind mounts.
  2. TTY disabled — the -T flag is added to docker compose run to prevent hanging.
  3. Plugin download is mandatory — fails hard if plugins can’t be downloaded.
  4. Test results are reported to Buildkite’s test analytics.

The management script auto-detects CI:

const isCI = process.env.CI === 'true' || process.env.BUILDKITE === 'true';

This single check controls:

  • Whether the override file is used.
  • Whether -T is passed to WP-CLI commands.
  • Whether plugin download failures are fatal.
  • Whether test results are streamed to Buildkite.

Troubleshooting Common Issues

Permission Issues with Bind Mounts

Docker may create root-owned files on your host. Fix with:

bun run site:cleanup

Port Conflicts

If another service uses port 8080-8084, change the URLs in .env:

PRO_OLD_BASE_URL=http://localhost:9080

The port is auto-extracted and passed to Docker Compose.

Database Not Ready

The init script retries database connections up to 30 times with a 2-second delay. If it still fails, check:

bun run site:logs

Stale Containers

If you see weird behavior after pulling new code:

bun run site:reset

This does a full cleanup and re-initialization.

Plugin Download Failures

If the remote build server is unreachable:

# Try a specific branch
bun run plugin:download -- develop

# Or manually place plugin ZIPs in docker/plugins/

Key Takeaways

  1. Docker Compose profiles let you run only the sites you need, saving resources.
  2. Override files cleanly separate local development (bind mounts) from CI (docker cp).
  3. A management script (in Bun/Node) abstracts Docker commands into simple workflows.
  4. MU-plugins expose application internals via REST APIs, making programmatic assertions easy.
  5. Environment-driven configuration means one .env file controls everything.
  6. “Old” sites with imported databases let you test upgrade paths alongside fresh installs.

This setup has cut our manual testing time significantly and given us confidence that both Lite and Pro versions work correctly across fresh installs and upgrades. The same environment runs locally and in CI, eliminating “works on my machine” issues entirely.

If you’re building a plugin with multiple versions or configurations, I hope this gives you a solid foundation to build your own automated testing pipeline.

If you found this post helpful, consider buying me a coffee. It keeps me writing!