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
- The Problem: Why You Need This
- Architecture Overview
- Project Structure
- Docker Compose: Defining the Multi-Site Stack
- The Override File: Local vs CI Environments
- The Management Script: Automating Everything
- MU-Plugins: Custom REST API Helpers for Testing
- Environment Configuration
- Integrating with Playwright for E2E Tests
- Running the Setup Step by Step
- CI/CD Integration with Buildkite
- 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:
- MariaDB — the database.
- application — the PHP/Apache web server.
- 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
/datavolume lets you mount SQL dumps accessible from inside every container. - Custom
uploads.iniincreases 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
| Scenario | Plugin Delivery Method | Override File Used? |
|---|---|---|
| Local dev | Bind mounts (instant changes) | Yes (auto-merged) |
| CI/CD | docker cp into containers | No (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
| Command | npm Script | Description |
|---|---|---|
init | bun run site:init | Download plugins, start containers, install application, import databases, activate plugins |
start | bun run site:start | Start existing containers (no setup) |
stop | bun run site:stop | Stop all running containers |
reset | bun run site:reset | Clean everything and re-initialize |
cleanup | bun run site:cleanup | Remove all containers, volumes, and data |
logs | bun run site:logs | Tail container logs |
download-plugins | bun run plugin:download | Download plugin builds from remote server |
sync-plugins | bun run plugin:sync | Copy plugins to running containers |
wp | — | Run WP-CLI commands on a specific site |
export-db | bun run db:export | Export a site’s database to a SQL file |
db ui | bun run db:phpmyadmin | Start 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
| Endpoint | What 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-structure | Current permalink structure setting |
GET /wp-json/e2e/v1/timezone | application timezone setting |
GET /wp-json/e2e/v1/date-format | Date format string |
GET /wp-json/e2e/v1/time-format | Time format string |
GET /wp-json/e2e/v1/plugins | All installed plugins with status and version |
GET /wp-json/e2e/v1/post-types | All public post types |
GET /wp-json/e2e/v1/taxonomies | All public taxonomies |
GET /wp-json/e2e/v1/reading-settings | All reading settings (homepage display, posts per page, etc.) |
GET /wp-json/e2e/v1/homepage-last-modified | ISO 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
- Docker Desktop installed and running.
- Bun runtime installed (
curl -fsSL https://bun.sh/install | bash). - 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:
- No override file — plugins are copied via
docker cpinstead of bind mounts. - TTY disabled — the
-Tflag is added todocker compose runto prevent hanging. - Plugin download is mandatory — fails hard if plugins can’t be downloaded.
- 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
-Tis 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
- Docker Compose profiles let you run only the sites you need, saving resources.
- Override files cleanly separate local development (bind mounts) from CI (docker cp).
- A management script (in Bun/Node) abstracts Docker commands into simple workflows.
- MU-plugins expose application internals via REST APIs, making programmatic assertions easy.
- Environment-driven configuration means one
.envfile controls everything. - “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.