CI/CD
Playwright Environment Variables: baseURL, Secrets and Several Environments
One config for local, staging and production runs, with secrets that never land in the repo or the test file.

Playwright environment variables do two jobs. They pick where the tests run (your laptop, staging or production), and they carry secrets such as a test account's password. Most problems come from mixing those jobs up. The URL gets hard-coded, the password ends up in a committed file, or a missing variable fills a blank into the login form. Then the test fails three steps later with an error that says nothing useful.
This guide builds one config that handles all of it: a .env file for local runs, a TEST_ENV switch for several environments, CI secrets in GitHub Actions and GitLab, and a check that stops a test early and names the missing variable. Then it shows where secrets leak anyway, with results from an actual run.
Everything here was run on 22 October 2026 with @playwright/test 1.61.1, dotenv 18.0.8 and Node 24, against a small local demo app. Download the examples to run them yourself.
TL;DR: Playwright environment variables in five rules
- Load
.envinplaywright.config.tswithdotenv. It never overwrites a variable that is already set, so the shell and CI always win. - Keep only URLs in the config. Map a
TEST_ENVname to a base URL, and throw on a name you don't know. - In CI, put secrets in the platform's secret store and pass them only to the step that runs the tests.
- Check secrets in a fixture, not at the top of a test file. A top-level throw stops the whole run,
--listincluded. - Secrets leak through test titles, failed assertions,
console.logand traces. Keep them out of all four, and treat reports and traces as sensitive files.

Load a .env file locally with dotenv
Playwright does not read .env files by itself. I checked: with a .env file in the project and nothing loading it, process.env.TEST_EMAIL was undefined inside the test. The Playwright docs suggest the dotenv package, loaded at the top of the config:
npm install --save-dev dotenv// playwright.config.ts (top of the file)
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'node:path';
// Local runs read .env next to this file. CI has no .env file: the pipeline sets
// real environment variables, and dotenv never overwrites a variable that is already set.
dotenv.config({ path: path.resolve(__dirname, '.env'), quiet: true });Then a .env.example that you commit, so everyone knows what to set:
# .env.example — copy to .env for local runs. Never commit .env.
# local | staging | production
TEST_ENV=local
# Base URLs per environment (not secret).
STAGING_BASE_URL=https://staging.example.com
PRODUCTION_BASE_URL=
# Test account (secret). Use a test-only account, never a real person's login.
TEST_EMAIL=
TEST_PASSWORD=And a .gitignore that keeps the real file out:
# .gitignore
.env
.env.*
!.env.exampleThree details that matter in practice:
The shell wins over .env. dotenv skips any variable that already exists in process.env. With TEST_ENV=local in .env, running TEST_ENV=staging npx playwright test targeted staging. That is the behaviour you want: CI and one-off command-line values override your local file without editing it. The flip side: a variable set to an empty string in your shell (export TEST_PASSWORD=) also blocks the .env value, and the test sees an empty password.
The config runs more than once. Without quiet: true, dotenv 18 printed its log line three times in a run with two workers: ◇ injected env (5) from .env from the main process, then ◇ injected env (0) from .env from each worker. Playwright loads the config in every worker process. The workers got 0 new values because they had already inherited all five from the main process. (The count is the number of keys in your .env, so yours may differ.) So variables loaded in the config do reach your tests, and quiet: true keeps the output clean. (dotenv 17 made the log line the default, per its changelog.)
Values reach Node, not the browser. process.env exists in your test code, which runs in Node. Inside page.evaluate(), typeof process was 'undefined'. Pass the value in as an argument: page.evaluate((v) => …, process.env.SOME_VALUE).
Without the dotenv package
Node 20.12+ and 21.7+ can read .env files itself with process.loadEnvFile(). It also left variables already set in the shell alone. Unlike dotenv, though, it throws ENOENT when the file is missing, and CI has no .env. Check first:
// playwright.config.ts (top of the file), without dotenv
import fs from 'node:fs';
import path from 'node:path';
const envFile = path.resolve(__dirname, '.env');
// loadEnvFile throws when the file is missing, and CI has no .env: check first.
if (fs.existsSync(envFile)) process.loadEnvFile(envFile);One config, several environments
The docs example switches baseURL on a single STAGING=1 flag. That works for two environments. With three or more, a named map is easier to read and safer to get wrong. Here is the complete config:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'node:path';
import { requireEnv } from './tests/support/env';
dotenv.config({ path: path.resolve(__dirname, '.env'), quiet: true });
// Every environment the suite may target. Only URLs live here, never secrets.
const environments = {
local: 'http://localhost:4181',
staging: process.env.STAGING_BASE_URL,
production: process.env.PRODUCTION_BASE_URL,
} as const;
type TestEnv = keyof typeof environments;
const testEnv = (process.env.TEST_ENV ?? 'local') as TestEnv;
// Object.hasOwn, not `in`: `in` also accepts inherited names such as "toString".
if (!Object.hasOwn(environments, testEnv)) {
throw new Error(
`Unknown TEST_ENV "${process.env.TEST_ENV}". Use one of: ${Object.keys(environments).join(', ')}.`
);
}
// BASE_URL wins, for one-off runs against a preview deployment.
const baseURL = process.env.BASE_URL || environments[testEnv];
if (!baseURL) {
throw new Error(`TEST_ENV=${testEnv} has no URL. Set ${testEnv.toUpperCase()}_BASE_URL.`);
}
// In CI a missing secret is a setup error, not a flaky test: stop before any
// test starts, so it is not retried. Production runs only @smoke tests, which need none.
if (process.env.CI && testEnv !== 'production') {
requireEnv('TEST_EMAIL', 'TEST_PASSWORD');
}
export default defineConfig({
testDir: './tests',
// Against production, run only the read-only tests tagged @smoke.
grep: testEnv === 'production' ? /@smoke/ : undefined,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL,
trace: 'on-first-retry',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
// Start the app only for local runs. Staging and production are already running.
webServer:
testEnv === 'local' && !process.env.BASE_URL
? {
command: 'node app/server.mjs',
url: 'http://localhost:4181/health',
reuseExistingServer: !process.env.CI,
}
: undefined,
});Replace node app/server.mjs with your app's start command. What each part does when you run it:
| Command | What happened in the demo |
|---|---|
npx playwright test |
TEST_ENV=local from .env; webServer started the app; 2 passed |
TEST_ENV=staging npx playwright test |
ran against STAGING_BASE_URL; no app started; 2 passed |
TEST_ENV=production npx playwright test |
stopped: Error: TEST_ENV=production has no URL. Set PRODUCTION_BASE_URL. |
TEST_ENV=production PRODUCTION_BASE_URL=… npx playwright test |
ran only the @smoke test; 1 passed |
TEST_ENV=prod npx playwright test |
stopped: Error: Unknown TEST_ENV "prod". Use one of: local, staging, production. |
BASE_URL=<any URL> npx playwright test |
ran against that URL, whatever TEST_ENV says; no app started |
The two throw lines are the important part. A config that quietly falls back to a default on a typo can send a "staging" run somewhere else. Both errors stop the run before a browser opens. The run exits with code 1 and prints the message first. The guard uses Object.hasOwn (ES2022) rather than in, because in also accepts inherited names: with TEST_ENV=toString, an in check let the run through, and both tests failed with an unhelpful use() was not called in fixture "baseURL".
The production guard uses a tag. Mark the tests that only read data:
// tests/home.spec.ts
import { test, expect } from '@playwright/test';
// Read-only, no secrets: safe to run against every environment.
test('home page loads', { tag: '@smoke' }, async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Demo shop' })).toBeVisible();
});Anything that creates orders, sends emails or changes settings stays off production by default. Someone has to change the config on purpose to run it there.
Why not one Playwright project per environment? You can give each project its own baseURL. But npx playwright test then runs every project, so a plain local run hits staging and production too, unless everyone remembers --project. Environments usually need different secrets as well, and those come from outside the config anyway. A single switch is harder to misuse.
Secrets in GitHub Actions and GitLab
In CI there is no .env file. The pipeline sets real environment variables from its secret store, and dotenv leaves them alone.
GitHub Actions
Store the test account under Settings → Environments (one environment per target), and the non-secret URLs as configuration variables. This workflow is based on Playwright's CI guide:
# .github/workflows/e2e.yml
name: e2e
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
inputs:
environment:
description: Environment to test
type: choice
options: [staging, production]
default: staging
jobs:
e2e:
runs-on: ubuntu-latest
# Picks the GitHub environment, so TEST_PASSWORD and the vars below come from
# that environment's settings. Pushes and pull requests test staging.
# deployment: false keeps test runs out of the environment's deployment history.
environment:
name: ${{ inputs.environment || 'staging' }}
deployment: false
env:
TEST_ENV: ${{ inputs.environment || 'staging' }}
STAGING_BASE_URL: ${{ vars.STAGING_BASE_URL }}
PRODUCTION_BASE_URL: ${{ vars.PRODUCTION_BASE_URL }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: lts/*
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Run Playwright tests
run: npx playwright test
env:
# Secrets only on the step that needs them.
TEST_EMAIL: ${{ secrets.TEST_EMAIL }}
TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report-${{ env.TEST_ENV }}
path: playwright-report/
retention-days: 7I checked this file against the published GitHub workflow schema, and ran the same command locally with the same variables and no .env file. I did not run it on GitHub itself. Things to know, from GitHub's docs as of October 2026:
- Environment secrets keep one name per target.
TEST_PASSWORDcan hold the staging password in thestagingenvironment and a different one inproduction. When a secret with the same name exists at several levels, the lowest level wins, so the environment's value beats the repository's. A job only gets an environment's secrets if it names that environment, and required reviewers can make a production run wait for approval. - A test job isn't a deployment. Without
deployment: false, every push and pull request would add a staging deployment to the environment's history. With it, the job still gets the environment's secrets and variables, and required reviewers and wait timers still apply, but no deployment is recorded. One exception: custom deployment protection rules need a deployment, so a job withdeployment: falsefails in an environment that uses them. - A missing secret is an empty string, not an error. If a secret isn't set,
${{ secrets.TEST_PASSWORD }}evaluates to an empty string. Your check has to treat empty as missing, asrequireEnvbelow does. - Pull requests from forks get no secrets. GitHub doesn't pass secrets to the runner for workflows triggered from a fork. Expect those runs to stop at the secret check. Keep them to tests that need no secrets, or skip the job for forks.
- Masking covers the job log, not your files. GitHub redacts secret values from the log. The HTML report and traces you upload keep whatever the tests wrote into them. The leak section below shows what that can be.
GitLab CI
In GitLab, set the variables under Settings → CI/CD → Variables, give each one an environment scope, and let the job claim that environment:
# .gitlab-ci.yml
# Use the image tag that matches @playwright/test in your lockfile.
image: mcr.microsoft.com/playwright:v1.61.1-noble
e2e-staging:
stage: test
# Gives the job the variables scoped to "staging" without creating a deployment.
environment:
name: staging
action: verify
variables:
TEST_ENV: staging
# TEST_EMAIL, TEST_PASSWORD and STAGING_BASE_URL come from
# Settings > CI/CD > Variables, with the environment scope "staging".
script:
- npm ci
- npx playwright test
artifacts:
when: always
paths:
- playwright-report/
expire_in: 1 weekThis one validated against GitLab's CI schema. From the GitLab variables docs and environments docs, as of October 2026:
- Masked variables have rules. The value must be a single line with no spaces and at least 8 characters. A short test password can't be masked, so give the test account a longer one. Symbols matter too: with Expand variable reference on, a masked value may only use
_ : @ - + . ~ = /besides letters and digits, and a$is read as a variable reference. For passwords with symbols, clear that checkbox, which allows every character. - Protected variables only reach protected branches and tags. A pipeline on a feature branch gets nothing. With the checks below, that shows up as a clear "missing variable" error, not a puzzling login failure.
action: verifygives a job the environment's variables without recording a deployment. A test job isn't a deployment.
For the image, caching, reports and parallel jobs, see Playwright in GitLab CI.
Fail fast when a variable is missing
process.env.TEST_PASSWORD! checks nothing. The ! only quiets TypeScript. At run time, an unset variable is undefined, and the test fails with locator.fill: value: expected string, got undefined, which doesn't say which variable to set. An empty string from CI is worse: fill('') succeeds, and the test fails later at whatever check follows the login. In the demo, that was a toHaveURL that still read /login.
Read secrets through one helper that names everything missing at once:
// tests/support/env.ts
/**
* Reads required environment variables. Throws one error that names every
* missing variable, instead of letting `undefined` reach a form field.
*/
export function requireEnv<K extends string>(...names: K[]): Record<K, string> {
const missing = names.filter((name) => !process.env[name]?.trim());
if (missing.length > 0) {
throw new Error(
`Missing environment variable(s): ${missing.join(', ')}. ` +
'Copy .env.example to .env and fill them in, or add them to your CI secrets.'
);
}
return Object.fromEntries(names.map((name) => [name, process.env[name]!.trim()])) as Record<K, string>;
}Call it from a fixture, so only tests that need the secrets ask for them:
// tests/fixtures.ts
import { test as base } from '@playwright/test';
import { requireEnv } from './support/env';
type Credentials = { email: string; password: string };
export const test = base.extend<{ credentials: Credentials }>({
// Only tests that ask for `credentials` need the secrets. A public-page test
// still runs on a machine that has none.
credentials: async ({}, use) => {
const env = requireEnv('TEST_EMAIL', 'TEST_PASSWORD');
await use({ email: env.TEST_EMAIL, password: env.TEST_PASSWORD });
},
});
export { expect } from '@playwright/test';// tests/login.spec.ts
import { test, expect } from './fixtures';
test('member signs in', async ({ page, credentials }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(credentials.email);
await page.getByLabel('Password').fill(credentials.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByText('Signed in as')).toBeVisible();
});Without a .env file, the smoke test passed and the login test failed with one readable line:
Error: Missing environment variable(s): TEST_EMAIL, TEST_PASSWORD. Copy .env.example to .env and fill them in, or add them to your CI secrets.Don't read secrets at the top of a test file. I tried const { TEST_PASSWORD } = requireEnv('TEST_PASSWORD') at module scope. Without the variable, npx playwright test --list printed the error and Total: 0 tests in 0 files, and a normal run failed before a single test ran, the secret-free smoke test included. Playwright imports every test file to collect the tests, so a top-level throw breaks collection for the whole run. Inside a fixture, the check runs only when a test that needs the secret starts. Without a .env file, --list still showed both tests.
In CI, check once in the config. CI usually has retries on. A missing secret is not flaky, but with retries: 2 the login test ran three times and failed three times with the same message. That is why the config above calls requireEnv when CI is set. The run then stopped with one error and no test attempts. Keep that check to CI: a throw in the config also stopped --list, which you want to work on a laptop without secrets.
If a test needs an optional integration rather than a core secret, skip it instead: test.skip(!process.env.STRIPE_TEST_KEY, 'STRIPE_TEST_KEY not set'). Use that sparingly. A skipped test in CI is easy to miss for months.
Where secrets leak: traces, reports, state files and logs
To see where secrets end up, I ran four bad tests with tracing on and the list, HTML, JUnit and JSON reporters. Then I searched every output file, and every file inside the trace and report zips, for the password and the API token. The test files and the search script are in the download (leaks/).
| Leak | Where the value showed up | Fix |
|---|---|---|
| The password in a test title | Terminal, JUnit name, JSON, HTML report, and the test-results folder name (…-with-demo-password-123/) |
Never interpolate a secret into test() or test.step() titles |
toHaveValue(password) failing |
Expected: "demo-password-123" in the terminal, JUnit, JSON, the HTML report and error-context.md |
Assert the outcome (the URL, a greeting), not the secret |
console.log of an env value |
Terminal, JUnit system-out, JSON and the trace |
Log variable names and whether they are set, never values |
A bearer token in extraHTTPHeaders |
The network log of every trace that made a request (not in JUnit or JSON) | Treat traces as secret; prefer short-lived test tokens |
Two more you already know about:
- Values passed to
fill()are stored in plain text in the trace's action list, and request bodies in its network log. See traces contain secrets for what a trace holds and what turning parts off does. storageStatefiles hold live sessions. Playwright's authentication guide warns that they can be used to impersonate your test account, and recommends a git-ignoredplaywright/.authfolder. Playwright authentication covers the setup.
What follows from the table:
- The HTML report and traces are secret files. CI log masking does not apply to them. Upload them only to storage your team controls, keep
retention-daysshort, and never attach one to a public issue. Playwright's CI guide gives the same advice. - Use test-only accounts and test-mode keys. Something will leak eventually. Make sure what leaks can only sign in to staging as a throwaway user.
- Search your own artifacts once. Run the suite with a known fake secret and search the output for it, as the download's
find-secret.shdoes. It takes minutes and shows you exactly what your setup writes.
How Blendra keeps secrets out of recorded tests
Recorded tests have the same problem in a sharper form. Code generators often write what you typed straight into the script, passwords included. Blendra handles it at recording time:
- When you record in Chrome, Blendra checks each field's name, id, label, placeholder and
autocompleteattribute. Seven kinds of field count as sensitive: passwords, card numbers, card security codes, national IDs, one-time codes, API tokens and secrets, and bank details. For those fields, the value you type is dropped in the browser and never stored with the step. - The exported Playwright project reads each value from an environment variable, such as
TEST_PASSWORDorTEST_CARD_NUMBER. Its.env.examplelists every secret the suite reads, with empty values, next to the environment URLs, and its.gitignoreleaves.envout of git. - A generated fixture checks those variables before each test runs. If one is missing, the test fails before its first step with the names of the missing variables. Because the check is a fixture, listing the tests still works on a machine without them.
config/environments.tspickslocal,stagingorproductionwithTEST_ENV, andBASE_URLoverrides it. An unknown name falls back to the project's default environment, so add this guide'sthrowif you want a typo to stop the run.
Detection is deliberately conservative. A secret typed into a field named notes is stored as typed, so check the Detected fields panel after you record. The details are in Sensitive values and Generated project structure.
FAQ
Does Playwright load .env files automatically?
No. Load the file in playwright.config.ts with dotenv, or with Node's process.loadEnvFile() behind an existence check. The config is loaded in the main process and in every worker, so the values reach all your tests.
How do I pass an environment variable on the command line?
On macOS and Linux, put it before the command: TEST_ENV=staging npx playwright test. In PowerShell, use $env:TEST_ENV="staging"; npx playwright test. For npm scripts that must work on every OS, the cross-env package sets variables the same way everywhere.
Why is my variable undefined inside page.evaluate?
That code runs in the browser, which has no process. Read the variable in the test, check it, and pass it in as an argument:
// Fragment: inside a test. requireEnv is the helper from the fail-fast section.
const { API_TOKEN } = requireEnv('API_TOKEN');
await page.evaluate((token) => localStorage.setItem('token', token), API_TOKEN);Passing process.env.API_TOKEN straight in fails strict type checking, and when the variable is unset the page stores the string "undefined". Keep in mind that the trace records what you pass.
Should I use a separate .env file per environment?
You can, but you rarely need to. The URLs fit in the config map, and CI never reads .env files. Locally, one git-ignored .env file plus TEST_ENV=staging on the command line covers most teams. If you do keep .env.staging and similar files, ignore them all in git, as the .gitignore above does.
Is it safe to run Playwright tests against production?
Only read-only checks, with a dedicated test account. The config above enforces that by running only @smoke tests when TEST_ENV=production, and the GitHub job can wait for an approval before it gets the production secrets.