Multi-Environment Configuration in Playwright: Dev, Staging, Prod
Hard-coding baseURL: 'http://localhost:3000' works fine until the day you need to run the exact same suite against staging before a release, or smoke-test production after a deploy. Suddenly your tests are littered with environment if statements and your CI logs are a guessing game. This guide shows you how to set up clean, type-safe Playwright multi-environment configuration so one command and one flag (ENV=staging) flips your entire suite between dev, staging, and prod without touching a single test file.
🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.
Contents
Why Multi-Environment Configuration Matters
A real test suite rarely lives in one place. You write and debug against a local dev server, gate merges on a shared staging deployment, and run a slim, read-only subset against production to verify a release actually shipped. Each of those targets has a different base URL, different credentials, a different rate of flakiness, and a different appetite for risk. Without a strategy, teams copy-paste config files or smother tests in branching logic, and the suite rots.
The goal is a single source of truth: one place that resolves the active environment, exposes a typed config object, and feeds it into Playwright’s use block. Tests stay environment-agnostic. They navigate to relative paths, read fixtures, and never know whether they are hitting localhost or app.example.com. That separation is what makes the same suite safe to run everywhere.
The Core Pattern: One Env Variable Drives Everything
The simplest robust approach is a single environment variable, conventionally ENV or TEST_ENV, that selects a named profile. Your playwright.config.ts reads that variable, looks up the matching profile, and wires it into the use options. Start by defining the shape of an environment and a typed lookup table.
// config/environments.ts
export type EnvName = 'dev' | 'staging' | 'prod';
export interface EnvConfig {
baseURL: string;
apiURL: string;
retries: number;
// Never put real secrets here — see the secrets section below.
isProd: boolean;
}
export const environments: Record<EnvName, EnvConfig> = {
dev: {
baseURL: 'http://localhost:3000',
apiURL: 'http://localhost:3000/api',
retries: 0,
isProd: false,
},
staging: {
baseURL: 'https://staging.example.com',
apiURL: 'https://staging.example.com/api',
retries: 1,
isProd: false,
},
prod: {
baseURL: 'https://app.example.com',
apiURL: 'https://app.example.com/api',
retries: 2,
isProd: true,
},
};
export function resolveEnv(): EnvConfig {
const name = (process.env.TEST_ENV ?? 'dev') as EnvName;
const config = environments[name];
if (!config) {
throw new Error(
`Unknown TEST_ENV "${name}". Expected one of: ${Object.keys(environments).join(', ')}`
);
}
return config;
}
The resolveEnv() function fails loudly on a typo instead of silently defaulting to production, which is exactly the behavior you want. Now consume it from the Playwright config.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import { resolveEnv } from './config/environments';
const env = resolveEnv();
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: env.retries,
reporter: process.env.CI ? 'github' : 'html',
use: {
baseURL: env.baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
});
Because baseURL is set, every test can use relative URLs. A call to page.goto('/login') resolves against whatever environment is active. Run the suite with TEST_ENV=staging npx playwright test and the entire thing retargets — no test edits required.
Loading Per-Environment .env Files
Hard-coded URLs in environments.ts are readable, but you often want non-secret values (URLs, feature flags, timeouts) to live in .env files that vary per machine or per environment. Use the dotenv package and load the file that matches the active environment at the very top of the config, before defineConfig runs.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';
const TEST_ENV = process.env.TEST_ENV ?? 'dev';
// Load .env.dev / .env.staging / .env.prod, then a shared .env as fallback.
dotenv.config({ path: path.resolve(__dirname, `.env.${TEST_ENV}`) });
dotenv.config({ path: path.resolve(__dirname, '.env') });
const baseURL = process.env.BASE_URL;
if (!baseURL) {
throw new Error(`BASE_URL missing. Did you create .env.${TEST_ENV}?`);
}
export default defineConfig({
use: { baseURL },
retries: Number(process.env.RETRIES ?? 0),
});
A matching .env.staging file is just plain key-value pairs:
// .env.staging (commit this if it holds no secrets; otherwise .gitignore it)
BASE_URL=https://staging.example.com
API_URL=https://staging.example.com/api
RETRIES=1
FEATURE_NEW_CHECKOUT=true
Two rules keep this safe. First, by default dotenv does not overwrite variables already present in process.env, so values injected by CI win over the file — which is what you want. Second, never commit real secrets to .env files. Passwords, API keys, and tokens belong in your CI secret store and are read straight from process.env at runtime. Commit a .env.example documenting the required keys with dummy values, and add the real files to .gitignore.
Using Projects for Environment Matrices
The single-variable approach is great for “run everything against one environment.” But sometimes you want to run different groups of tests with different settings in one command — for example, a fast set of API tests with zero retries plus a slower browser set. Playwright’s projects array is the tool. Each project can carry its own use overrides and even its own baseURL.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import { resolveEnv } from './config/environments';
const env = resolveEnv();
export default defineConfig({
projects: [
{
name: 'api',
testMatch: /.*\.api\.spec\.ts/,
use: { baseURL: env.apiURL },
retries: 0,
},
{
name: 'ui-chromium',
testMatch: /.*\.ui\.spec\.ts/,
use: { baseURL: env.baseURL },
retries: env.retries,
},
],
});
Run a single project with npx playwright test --project=api, or both at once by omitting the flag. Combine this with TEST_ENV and you get a clean matrix: TEST_ENV=staging npx playwright test --project=ui-chromium runs only the UI suite against staging.
Sharing the Active Config Inside Tests via Fixtures
Sometimes a test legitimately needs an environment value — say, the API URL for a direct request, or a feature flag to decide which assertion path to take. Reaching into process.env from inside tests is messy and untyped. The clean solution is a custom fixture that exposes the resolved config object.
// fixtures/env-fixture.ts
import { test as base } from '@playwright/test';
import { resolveEnv, type EnvConfig } from '../config/environments';
type EnvFixtures = {
env: EnvConfig;
};
export const test = base.extend<EnvFixtures>({
env: async ({}, use) => {
await use(resolveEnv());
},
});
export { expect } from '@playwright/test';
Tests import test from this file instead of @playwright/test, and the typed env object is injected automatically. Guarding destructive actions behind env.isProd is a common, valuable pattern.
// tests/checkout.ui.spec.ts
import { test, expect } from '../fixtures/env-fixture';
test('user can view cart', async ({ page, env }) => {
await page.goto('/cart'); // relative — resolves against baseURL
await expect(page.getByRole('heading', { name: 'Your Cart' })).toBeVisible();
// Direct API check using the environment-specific API URL.
const res = await page.request.get(`${env.apiURL}/health`);
expect(res.ok()).toBeTruthy();
});
test('admin can delete a test order', async ({ page, env }) => {
test.skip(env.isProd, 'Destructive test — never runs against production');
await page.goto('/admin/orders');
await page.getByRole('button', { name: 'Delete' }).first().click();
await expect(page.getByText('Order deleted')).toBeVisible();
});
That test.skip(env.isProd, ...) line is a guardrail: the same file can live in your suite, but the destructive test simply does not execute in production. No accidental data deletion, no separate prod-only branch of the codebase.
Per-Environment Auth with storageState
Login differs by environment — different users, different identity providers, different session cookies. Rather than logging in at the start of every test, authenticate once per environment in a setup project and persist the session with storageState. Keep the state file keyed by environment so dev and staging sessions never collide.
// auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import { resolveEnv } from './config/environments';
const env = resolveEnv();
const authFile = `playwright/.auth/${process.env.TEST_ENV ?? 'dev'}.json`;
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USER!);
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
// Persist cookies + localStorage for reuse across all tests.
await page.context().storageState({ path: authFile });
});
Wire the setup project as a dependency so it runs first, then point your main project at the per-environment state file. The credentials come from process.env (injected by CI secrets), never from a committed file.
// playwright.config.ts (excerpt)
import { defineConfig } from '@playwright/test';
const TEST_ENV = process.env.TEST_ENV ?? 'dev';
const authFile = `playwright/.auth/${TEST_ENV}.json`;
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: { storageState: authFile },
dependencies: ['setup'],
},
],
});
Now every test in the chromium project starts already logged in with the correct environment’s session. Add playwright/.auth/ to .gitignore — those state files contain live tokens.
🚀 Level Up Your Playwright
From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.
Comparison: Which Approach Fits Your Suite?
| Approach | Best for | Switches via | Trade-off |
|---|---|---|---|
Single TEST_ENV + typed table | Most teams; one env per run | TEST_ENV=staging | All tests hit one target per run |
Per-env .env files + dotenv | Many tunable, non-secret values | .env.<name> file | Must manage several files |
Multiple Playwright projects | Different test groups / settings | --project=api | More config verbosity |
| Multiple config files | Wildly different setups per env | --config=staging.config.ts | Duplication across configs |
For the majority of suites, the single TEST_ENV variable feeding a typed table is the sweet spot: it is explicit, fails loudly on typos, and keeps tests completely environment-agnostic. Reach for separate config files only when environments diverge so heavily that a shared file becomes a tangle of conditionals.
Wiring It Into CI and package.json Scripts
Expose ergonomic scripts so engineers never have to remember the variable name. Add these to package.json and your CI workflow injects secrets through its own environment, leaving the file values for non-secrets.
// package.json (scripts section)
{
"scripts": {
"test:dev": "TEST_ENV=dev playwright test",
"test:staging": "TEST_ENV=staging playwright test",
"test:prod": "TEST_ENV=prod playwright test --grep @smoke"
}
}
Note the production script restricts to @smoke-tagged tests with --grep. Production runs should be a tiny, read-only, non-destructive subset — health checks and critical-path verification, not your full regression suite. Tag tests with @smoke in their titles and only those execute against prod.
- Dev: zero retries, full suite, fast feedback while writing tests.
- Staging: the merge gate — full regression with one retry to absorb flake.
- Prod: smoke tag only, more retries, strictly non-destructive checks.
That layered strategy is the heart of practical Playwright multi-environment configuration: a single variable resolves a typed profile, fixtures expose it cleanly to tests, storageState keeps auth per-environment, and CI scripts make the right thing the easy thing. With this in place, retargeting your entire suite from dev to staging to prod is a one-word change — and production stays protected from anything destructive by design.
FAQ
How do I switch Playwright between dev, staging, and prod?
Read a single environment variable such as TEST_ENV at the top of playwright.config.ts, use it to look up a typed profile (base URL, retries, flags), and feed that into the use block. Then run TEST_ENV=staging npx playwright test. Because baseURL is set, tests use relative paths like page.goto('/login') and never need editing to retarget.
Where should I store environment secrets like passwords and API keys?
Never in committed .env files. Keep non-secret values (URLs, timeouts, feature flags) in per-environment .env files, but store passwords, tokens, and keys in your CI provider’s secret store and read them at runtime via process.env. Commit a .env.example with dummy placeholders and add the real .env.* files plus playwright/.auth/ to .gitignore.
How do I stop destructive tests from running against production?
Expose an isProd flag on your environment config and call test.skip(env.isProd, 'reason') inside any destructive test, so it simply does not run in prod. Additionally, restrict production runs to a tagged subset with playwright test --grep @smoke so only safe, read-only checks ever execute against live data.
🎓 Master Playwright End to End
Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.
