QA How-To
Playwright webServer config: Examples and Best Practices
Use playwright webServer config examples for Vite, Next.js, monorepos, APIs, Docker, HTTPS, CI, and remote environments with reliable readiness patterns.
20 min read | 3,189 words
TL;DR
These Playwright webServer config examples cover Vite, Next.js, split frontend and API systems, monorepos, Docker Compose, HTTPS, workers, and remote targets. Each recipe uses a fixed address, explicit readiness, a matching baseURL, bounded startup, and CI-safe reuse behavior.
Key Takeaways
- Choose a dev-server recipe for fast iteration and a built preview recipe for release-like asset coverage.
- Make every start command bind a fixed host and port, and enable strict port behavior when the framework supports it.
- Use separate named webServer entries for frontend and API packages in a monorepo.
- Conditionally omit webServer when BASE_URL targets an already deployed environment.
- Keep Docker orchestration in the foreground so Playwright can own readiness and shutdown.
- Diagnose the start command and readiness endpoint before changing Playwright or browser timeouts.
These playwright webServer config examples provide copy-ready starting points for Vite, Next.js, APIs, monorepos, Docker Compose, HTTPS, background workers, and CI. The correct recipe depends on whether the suite needs fast development startup, a production build, multiple local services, or an already deployed target.
Each example keeps four contracts visible: which command owns the process, what signal means ready, which URL browsers use, and how the process stops. Adapt script names and health paths to the real application instead of treating a port as proof of readiness.
TL;DR
| Application shape | Recommended recipe | Readiness |
|---|---|---|
| Vite during development | npm run dev:e2e | Root page or health route |
| Vite release check | build plus preview | Built app health route |
| Next.js local suite | next dev or build plus start | App route that completes compilation |
| SPA plus API | Two named webServer entries | One check per service |
| Monorepo | Per-entry cwd and fixed port | Package-specific health route |
| Docker stack | Foreground compose command | Public gateway readiness |
| Remote environment | Omit webServer conditionally | External monitoring or preflight |
| Non-HTTP worker | wait.stdout or wait.stderr | Stable ready message |
A compact Vite example:
import { defineConfig } from '@playwright/test';
const appURL = 'http://127.0.0.1:4173';
export default defineConfig({
use: {
baseURL: appURL,
},
webServer: {
command: 'npm run dev:e2e',
url: appURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
1. Start With the Common Shape Behind All playwright webServer config examples
A webServer entry needs a command plus either an HTTP URL or output wait condition. Most browser applications should use a URL.
type PracticalServerRecipe = {
command: string;
url: string;
reuseExistingServer: boolean;
timeout: number;
};
The actual Playwright type supports additional fields such as name, cwd, env, stdout, stderr, ignoreHTTPSErrors, gracefulShutdown, and wait.
Build the recipe from application behavior:
- The command stays attached while the server runs.
- It exits nonzero when startup cannot succeed.
- It binds a known host and port.
- The readiness path returns only when required dependencies work.
- The browser baseURL uses the same origin.
- Playwright can stop the launched process tree.
Avoid hidden orchestration. A script named test:e2e:serve is useful if its package.json definition is reviewed and deterministic. A script that backgrounds three services, ignores failures, and exits immediately gives Playwright no process to own.
webServer belongs at the top of defineConfig, not inside use and not inside an individual project. Browser projects share the run-level servers. If two projects truly require different application processes or data environments, prefer separate Playwright invocations with separate configs instead of racing ports inside one run.
The Playwright webServer configuration guide explains lifecycle and option semantics in depth. The sections here emphasize working recipes and tradeoffs.
2. Vite Development Server Example
Create an E2E-specific script with a fixed interface:
{
"scripts": {
"dev:e2e": "vite --host 127.0.0.1 --port 4173 --strictPort",
"test:e2e": "playwright test"
}
}
strictPort matters. Without it, Vite can select another port when 4173 is occupied, while Playwright continues probing 4173.
Configure Playwright:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
const appURL = 'http://127.0.0.1:4173';
export default defineConfig({
testDir: './tests',
use: {
baseURL: appURL,
trace: 'on-first-retry',
},
webServer: {
name: 'Vite Dev',
command: 'npm run dev:e2e',
url: appURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: process.env.CI ? 'pipe' : 'ignore',
stderr: 'pipe',
},
});
The root page is acceptable when it proves the app is compiled and can serve JavaScript. If the app needs an API before any test is useful, use /health/ready and make that route dependency-aware.
A smoke test stays simple:
import { test, expect } from '@playwright/test';
test('serves the application shell', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Task Board' }))
.toBeVisible();
});
Use this recipe for local feedback and component integration. A development server may transform assets differently from the deployed build, so retain a built-app job for risks such as asset paths, environment replacement, compression, and production-only rendering.
3. Vite Production Preview Example
Run the build before preview:
{
"scripts": {
"build:e2e": "vite build --mode test",
"preview:e2e": "vite preview --host 127.0.0.1 --port 4173 --strictPort",
"test:e2e": "playwright test"
}
}
One webServer command can chain the build and long-running preview:
import { defineConfig } from '@playwright/test';
const appURL = 'http://127.0.0.1:4173';
export default defineConfig({
webServer: {
name: 'Vite Preview',
command: 'npm run build:e2e && npm run preview:e2e',
url: appURL + '/health',
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
},
use: {
baseURL: appURL,
},
});
reuseExistingServer is false here because the purpose is to validate the freshly built assets. Reusing a developer's old preview defeats that purpose.
The chained command is cross-platform when npm selects the platform shell, but complex preparation is easier to maintain in package scripts or a Node-based orchestrator. Ensure the long-running server is the final foreground process.
Do not rebuild once per browser project. webServer starts at run scope, so Chromium, Firefox, and WebKit can share the same immutable build. Tests must still isolate server-side data.
For a pull request pipeline, choose whether the dev-server suite or preview suite is the required gate. Many teams run fast targeted tests against dev mode, then a smaller smoke pack against the production build. The choice should reflect where configuration and bundling defects have occurred.
4. Next.js Development and Production Recipes
A development recipe uses a fixed host and port:
{
"scripts": {
"dev:e2e": "next dev --hostname 127.0.0.1 --port 3000",
"build": "next build",
"start:e2e": "next start --hostname 127.0.0.1 --port 3000"
}
}
const appURL = 'http://127.0.0.1:3000';
export default defineConfig({
webServer: {
name: 'Next Dev',
command: 'npm run dev:e2e',
url: appURL + '/login',
reuseExistingServer: !process.env.CI,
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
},
use: {
baseURL: appURL,
},
});
The first route in development may compile lazily. A readiness path should exercise enough of the app that the first test does not inherit compilation delay, but it should remain fast and non-mutating.
For a production build:
export default defineConfig({
webServer: {
name: 'Next Production',
command: 'npm run build && npm run start:e2e',
url: 'http://127.0.0.1:3000/health/ready',
reuseExistingServer: false,
timeout: 240_000,
stdout: 'pipe',
stderr: 'pipe',
},
use: {
baseURL: 'http://127.0.0.1:3000',
},
});
Server-rendered applications often require runtime environment variables during both build and start. Make the distinction explicit. A public browser variable may be baked into the build, while a database URL belongs only to the server process.
If build is already a dedicated CI step, set command to npm run start:e2e and let CI fail earlier on build output. This shortens the webServer responsibility and makes reports easier to classify.
5. Frontend Plus API Example
Use an array when the browser app and API are separate processes:
import { defineConfig } from '@playwright/test';
const webURL = 'http://127.0.0.1:4173';
const apiURL = 'http://127.0.0.1:3333';
export default defineConfig({
webServer: [
{
name: 'API',
command: 'npm run start:e2e',
cwd: './apps/api',
url: apiURL + '/health/ready',
env: {
APP_ENV: 'e2e',
},
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: 'pipe',
stderr: 'pipe',
},
{
name: 'Web',
command: 'npm run dev:e2e',
cwd: './apps/web',
url: webURL,
env: {
API_BASE_URL: apiURL,
},
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: 'pipe',
stderr: 'pipe',
},
],
use: {
baseURL: webURL,
},
});
Every service gets its own readiness target. The API route should verify the database or other mandatory dependencies. The web route should verify the browser-facing app is available.
Do not assume array order is a dependency guarantee. Multiple services can start around the same time. The frontend should tolerate the API starting, and web readiness should represent the state tests need.
When one command already manages both processes reliably, a single entry may be simpler:
webServer: {
command: 'npm run start:stack:e2e',
url: 'http://127.0.0.1:4173/health/ready',
timeout: 180_000,
}
The orchestrator must stay in the foreground and terminate its children when signaled. Choose the boundary with the clearest failure and cleanup behavior.
6. Monorepo Example With Explicit Working Directories
A repository may keep Playwright under packages/e2e while apps live elsewhere. Resolve paths from the configuration file:
import path from 'node:path';
import { defineConfig } from '@playwright/test';
const root = __dirname;
const portalURL = 'http://127.0.0.1:4300';
export default defineConfig({
testDir: path.join(root, 'tests'),
webServer: {
name: 'Admin Portal',
command: 'npm run start:e2e',
cwd: path.join(root, '../admin-portal'),
url: portalURL + '/health/ready',
reuseExistingServer: !process.env.CI,
timeout: 150_000,
},
use: {
baseURL: portalURL,
},
});
An explicit cwd prevents behavior from depending on the terminal directory used to launch Playwright. It also ensures package-local configuration and node_modules resolution behave as expected.
If the monorepo uses workspace commands from the root, keep cwd at root and call the workspace deliberately:
webServer: {
command: 'npm run dev:e2e --workspace=@acme/admin-portal',
cwd: __dirname,
url: 'http://127.0.0.1:4300/health/ready',
}
Pick one style based on the package manager and repository conventions. Do not mix relative paths that are sometimes resolved from the config and sometimes from a shell wrapper.
Keep output names distinct across packages. A named prefix such as Admin Portal or Orders API makes CI startup failures immediately attributable.
7. Local, CI, and Remote Environment Example
A single config can support all three modes:
import { defineConfig } from '@playwright/test';
const remoteURL = process.env.BASE_URL;
const localURL = 'http://127.0.0.1:4173';
const targetURL = remoteURL ?? localURL;
export default defineConfig({
use: {
baseURL: targetURL,
trace: 'on-first-retry',
},
webServer: remoteURL
? undefined
: {
name: 'Local App',
command: 'npm run start:e2e',
url: localURL + '/health/ready',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: process.env.CI ? 'pipe' : 'ignore',
stderr: 'pipe',
},
});
Behavior is clear:
| Execution | BASE_URL | CI | Result |
|---|---|---|---|
| Developer with app running | Absent | False | Reuse local server |
| Developer without app | Absent | False | Start local command |
| Clean CI checkout | Absent | True | Start new local command |
| Staging smoke run | Present | Usually True | Do not start local command |
Validate remote URLs and restrict destructive suites. A staging command should not accidentally accept a production URL. Use an allowlist or environment marker before data-creating tests.
Remote readiness is outside webServer when it is omitted. Add a setup project or CI preflight that checks the environment and fails with a clear message. Do not start a no-op local command just to make webServer fit a deployed target.
Read Playwright environment configuration for typed variables, secret handling, and target safeguards.
8. Docker Compose Example
Keep Docker Compose attached so Playwright can own its process:
import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: {
name: 'Docker E2E Stack',
command: 'docker compose -f compose.e2e.yml up --build',
url: 'http://127.0.0.1:8080/health/ready',
reuseExistingServer: false,
timeout: 240_000,
stdout: 'pipe',
stderr: 'pipe',
gracefulShutdown: {
signal: 'SIGTERM',
timeout: 15_000,
},
},
use: {
baseURL: 'http://127.0.0.1:8080',
},
});
Do not add detached mode. If docker compose up exits while containers continue, Playwright loses direct lifecycle ownership and cleanup becomes unreliable.
The public gateway readiness path should stay unavailable until mandatory services and migrations are usable. Compose health checks help internal service ordering, while webServer.url proves the browser-facing boundary.
Docker shutdown needs SIGTERM. The configured grace period allows Compose to stop containers. On platforms where Playwright graceful signals are unsupported, use a CI cleanup step or a platform-specific wrapper that still terminates only resources belonging to the job.
Persistent volumes and networks may need explicit cleanup after the suite, especially when tests require pristine data. Keep that responsibility in CI or the orchestration script and avoid broad commands that remove unrelated developer containers.
The Playwright Docker testing guide covers browser images, shared memory, user permissions, and service networking.
9. HTTPS and Local Certificate Example
Configure readiness and browser contexts separately:
import { defineConfig } from '@playwright/test';
const secureURL = 'https://127.0.0.1:3443';
export default defineConfig({
webServer: {
name: 'Local HTTPS App',
command: 'npm run start:https:e2e',
url: secureURL + '/health/ready',
ignoreHTTPSErrors: true,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
use: {
baseURL: secureURL,
ignoreHTTPSErrors: true,
},
});
webServer.ignoreHTTPSErrors applies to the readiness probe. use.ignoreHTTPSErrors applies to browser contexts. Setting one does not automatically configure the other.
Use this only for a controlled self-signed test certificate. Certificate trust, expiry, hostname mismatch, and security warnings may be part of production acceptance criteria, so do not globally suppress them in monitoring.
An alternative is to install a trusted local certificate authority in the test image. That more closely represents real TLS but adds environment management. Choose based on whether TLS trust is under test or merely a local development obstacle.
Keep secureURL consistent. Using https for baseURL and http for health can prove only that an unrelated listener is ready. It can also hide redirect or certificate startup failures.
10. Output-Based Worker Example
Some suites need a queue worker in addition to the web app. The worker has no HTTP route but prints a stable readiness line.
export default defineConfig({
webServer: [
{
name: 'Worker',
command: 'npm run worker:e2e',
cwd: './apps/worker',
wait: {
stdout: /E2E worker ready/,
},
timeout: 60_000,
stdout: 'pipe',
stderr: 'pipe',
},
{
name: 'Web',
command: 'npm run start:e2e',
cwd: './apps/web',
url: 'http://127.0.0.1:3000/health/ready',
timeout: 120_000,
},
],
use: {
baseURL: 'http://127.0.0.1:3000',
},
});
Output matching supports stdout and stderr regular expressions. Use a purpose-built message emitted only after subscriptions, credentials, and dependencies are ready.
Named capture groups can place matched values in process.env:
wait: {
stdout: /Worker ready on queue (?<queue_name>[a-z0-9_-]+)/,
}
Fixed browser ports remain easier because baseURL is evaluated as part of configuration. Do not use dynamic output capture to create unnecessary complexity for the main web app.
If both url and wait are configured on one entry, either condition can mark it ready. That is useful only when both are independently sufficient. It is not a way to require both a log line and healthy endpoint.
11. CI Example With Built Assets and Artifacts
Keep the workflow small because config owns the server:
name: playwright
on:
pull_request:
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run test:e2e
env:
CI: "true"
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
The associated config can build and preview:
webServer: {
command: 'npm run build:e2e && npm run preview:e2e',
url: 'http://127.0.0.1:4173/health',
reuseExistingServer: !process.env.CI,
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
}
In CI, !process.env.CI becomes false, so an unexpected process at the address fails the run. Locally this expression is true, which is convenient but may reuse an old build. Set false unconditionally for release verification.
Do not use a workflow step that runs npm start in the background plus a fixed sleep. webServer already supplies process ownership, readiness polling, timeout, output, and shutdown.
Retain the Playwright report and any application logs that are not already piped. Startup failures happen before browser traces exist, so server output is the primary evidence.
12. Review playwright webServer config examples With a Decision Checklist
Before committing a recipe, answer these questions:
- Does command stay alive and own its child processes?
- Does the server fail rather than select a surprise port?
- Does url prove the app and critical dependencies are usable?
- Do url and baseURL use the same origin?
- Is reuse disabled for clean or release-like CI?
- Is timeout based on cold-start evidence?
- Are logs visible when startup fails?
- Does cwd point at the correct package?
- Are secrets injected rather than committed?
- Can the process stop cleanly and release its port?
- Are multiple services independent of array ordering?
- Is a remote target protected from destructive test data?
A small configuration is preferable when it satisfies the architecture. Add options because a known boundary requires them, not because another project uses them.
For browser-level troubleshooting after startup succeeds, consult Playwright debugging with Trace Viewer. If tests fail before a browser opens, stay focused on command, environment, output, and readiness.
Interview Questions and Answers
Q: Which webServer recipe would you use for Vite in CI?
For a release-like job, I run the Vite build followed by a fixed-port preview server, disable reuse, and poll a health path. For faster PR feedback, a dev-server recipe can be valid if a separate build check covers production assets.
Q: Why enable Vite strictPort?
Without strict port behavior, Vite may move to a different port when the configured one is occupied. Playwright would continue polling the original URL. Failing early exposes the actual collision.
Q: How do you configure frontend and API servers?
I use two named entries with separate cwd, command, and readiness URL. The frontend receives the API origin through application environment, and use.baseURL points to the browser-facing frontend.
Q: How do you switch between local and deployed targets?
I derive targetURL from BASE_URL. When it is present, webServer is undefined; otherwise Playwright starts the local command. I validate remote targets before destructive tests.
Q: Should Docker Compose run detached?
No, not when webServer owns it. A foreground compose process lets Playwright track readiness and request shutdown. Detached containers require a separate explicit lifecycle.
Q: When do you use output readiness?
I use it for non-HTTP workers or tools that emit a stable machine-readable ready line after dependencies initialize. HTTP applications should normally expose a meaningful readiness URL.
Q: Why configure ignoreHTTPSErrors twice?
One option affects Playwright's server-readiness request, and the use option affects browser contexts. Both are needed for a self-signed local endpoint, but neither should hide production certificate problems.
Q: How do you prevent stale builds from being tested?
For production preview or CI, I set reuseExistingServer to false and make build part of a prior step or the start command. The process must be tied to the current checkout and fixed target.
Common Mistakes
- Copying a framework recipe without aligning package scripts and ports.
- Letting a dev server auto-select another port.
- Reusing an existing server for a clean production-build check.
- Running the build once per test or browser project.
- Using the root page as readiness when it ignores a required backend.
- Assuming the frontend entry always starts after the API entry.
- Resolving monorepo paths from an unpredictable terminal directory.
- Setting BASE_URL but still launching a local server.
- Running Docker Compose detached and leaking job resources.
- Matching a generic worker log message before the worker is usable.
- Expecting url plus wait to require both conditions.
- Suppressing HTTPS errors in production monitoring.
- Hiding startup output in CI.
- Adding browser sleeps for a server lifecycle defect.
Conclusion
The most useful playwright webServer config examples are recipes with visible ownership and readiness, not just command strings. Pick the application shape, bind a fixed address, match baseURL, choose local reuse versus clean CI startup, and keep shutdown under the runner's control.
Adopt the smallest relevant recipe from this guide, then test it from a clean checkout and run it twice. The first run proves startup and application readiness. The second proves cleanup. That two-run check catches many lifecycle defects before they become intermittent CI failures.
Interview Questions and Answers
Design a Vite webServer recipe for local and CI use.
I create a fixed-host and fixed-port dev:e2e script with strictPort. webServer polls that origin, baseURL uses the same value, and reuseExistingServer is !process.env.CI. CI pipes output and uses a measured startup timeout.
When would you run Vite preview instead of dev?
I use preview after a production build when the suite must cover built asset paths, mode replacement, chunk loading, or deployment-like behavior. I disable server reuse so the checked-out build is guaranteed. Dev mode remains useful for faster authoring.
How would you handle Next.js lazy compilation?
I select a readiness route that causes the necessary application area to compile and waits until it responds. I still use web-first assertions in tests. For release checks, I build first and run the production server instead.
What is your monorepo strategy?
Each service gets an explicit cwd or workspace command, unique name, fixed port, and health route. Paths resolve from the config rather than the caller's terminal. Shared endpoints are passed through typed environment configuration.
How do you manage a frontend and API dependency?
I configure two server entries and make each independently startable and observable. The frontend tolerates concurrent API startup, while its readiness represents the state the suite needs. I do not rely on array order.
How do you run the same suite against staging?
I accept a validated BASE_URL and omit local webServer when it is present. A preflight checks environment identity and health. Destructive tests require additional safeguards and isolated test data.
Why should Docker Compose stay in the foreground?
Playwright needs a long-running process it can own and terminate. Detached containers outlive the command and require separate cleanup. A foreground compose process plus SIGTERM gives a clearer lifecycle.
What is a good output wait message?
It is unique, stable, and emitted only after the process can perform test work. For a worker, that means subscriptions and dependencies are initialized. Generic words like started are not sufficient.
How do you choose between one orchestration command and several entries?
I choose the boundary that produces the clearest readiness, logs, failures, and cleanup. Separate entries are transparent for independent services. One mature orchestrator is better when it already owns the process tree reliably.
How do you prevent a random port fallback?
I configure a fixed host and port and enable strict port behavior when the framework supports it. A collision must fail at startup. Dynamic fallback would make Playwright poll a different address from the server.
What evidence do you retain for startup failures?
I pipe server stdout and stderr in CI and enable pw:webserver logging during diagnosis. Browser traces may not exist because no browser test started. I also retain orchestrator logs when Docker or multiple services are involved.
What simple test validates lifecycle quality?
I execute a focused suite twice from the same clean environment. The first run validates command and readiness. The second validates that the process tree released its port and resources.
Frequently Asked Questions
What is a good Vite Playwright webServer configuration?
Use an E2E script that binds 127.0.0.1 to a fixed port with strictPort, configure that URL in webServer, and use the same origin as baseURL. Choose dev mode for fast feedback or build plus preview for release-like coverage.
How do I use Playwright webServer with Next.js?
Run a fixed-host, fixed-port next dev command for local iteration, or next build followed by next start for production behavior. Point readiness at a route that has completed required compilation and dependencies.
Can webServer start an API and frontend together?
Yes. Provide an array with one named entry per service, each with its own command, cwd, URL, and timeout. Pass the API address to the frontend and set browser baseURL to the frontend.
How do I skip webServer for a remote BASE_URL?
Compute the target URL from process.env.BASE_URL and set webServer to undefined when that variable exists. Add a remote preflight and safeguards before running tests that mutate data.
Can Playwright webServer run Docker Compose?
Yes. Keep docker compose up in the foreground, use the public application health URL, and configure a bounded SIGTERM graceful shutdown. Plan explicit cleanup for volumes or resources that must not persist.
Should I use a dev server or production preview?
Use a dev server for rapid test authoring and source-level diagnostics. Use a built preview or production start command when asset paths, environment replacement, server rendering, or release configuration are part of the risk.
How do I configure a webServer in a monorepo?
Set cwd to the package that owns the start script, or run an explicit workspace command from the repository root. Use unique names, fixed ports, and package-specific health paths.
How can I verify Playwright stops the server?
Run a focused suite twice without manually killing processes. If the second run encounters a port collision, inspect child-process ownership and signal handling rather than adding a broad port-kill command.
Related Guides
- Playwright drag and drop: Examples and Best Practices
- Playwright mock date and time: Examples and Best Practices
- Playwright popup and new tab handling: Examples and Best Practices
- Playwright projects config: Examples and Best Practices
- Playwright tag and grep filters: Examples and Best Practices
- Playwright APIRequestContext: Examples and Best Practices