Hardcoding baseURL in playwright.config.ts means switching environments requires editing the file. Someone forgets to revert it, tests run against the wrong environment, and everything silently passes against stale data. This article covers reading base URL and credentials from environment variables, loading .env files locally, passing secrets through GitHub Actions, and wrapping credentials in fixtures so a missing variable fails at setup instead of mid-test.

The problem with hardcoded URLs

// Bad — hardcoded, no flexibility
use: {
  baseURL: 'https://staging.myapp.com',
},

When you need to run locally: you change the URL manually. When CI needs to run against a feature branch deploy: someone remembers to change it back. Someone forgets. Tests run against the wrong environment. The error is confusing because everything passes, just against stale data.

Environment variables: the correct approach

Read the base URL from an environment variable:

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
  },
});

Run tests:

# Local
npx playwright test

# Staging
BASE_URL=https://staging.myapp.com npx playwright test

# Production (smoke tests only)
BASE_URL=https://myapp.com npx playwright test --grep @smoke

No code changes between environments. Just a different environment variable.

Using dotenv for local development

Repeating BASE_URL=... every time is annoying. Use a .env file:

# .env.local
BASE_URL=http://localhost:3000
API_TOKEN=dev-token-abc123
TEST_USER_EMAIL=test@example.com
TEST_USER_PASSWORD=testpass123

Load it in your config:

npm install -D dotenv

// playwright.config.ts
import { defineConfig } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';

// Load .env.local if it exists, then fall back to .env
dotenv.config({ path: path.resolve(__dirname, '.env.local') });
dotenv.config({ path: path.resolve(__dirname, '.env') });

export default defineConfig({
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
  },
});

Add .env.local to .gitignore. Commit .env with safe defaults (no secrets). Team members create their own .env.local for local credentials.

Multiple environment configs

For projects with significantly different settings per environment, use separate config files:

playwright.config.ts           # Base config, shared settings
playwright.config.staging.ts   # Staging overrides
playwright.config.prod.ts      # Production overrides (smoke only)

// playwright.config.staging.ts
import { defineConfig } from '@playwright/test';
import baseConfig from './playwright.config';

export default defineConfig({
  ...baseConfig,
  use: {
    ...baseConfig.use,
    baseURL: 'https://staging.myapp.com',
    video: 'on-first-retry',
  },
  retries: 2,
});

Run with:

npx playwright test --config=playwright.config.staging.ts

This pattern works well for CI pipelines where different stages need different retry counts, video settings, or reporter configurations.

Accessing environment variables in tests

Don't pass environment variables through test files directly; that couples test logic to infrastructure. Use fixtures instead:

// fixtures/env.ts
import { test as base } from '@playwright/test';

type EnvFixtures = {
  testUser: { email: string; password: string };
  apiToken: string;
};

export const test = base.extend<EnvFixtures>({
  testUser: async ({}, use) => {
    const email = process.env.TEST_USER_EMAIL;
    const password = process.env.TEST_USER_PASSWORD;

    if (!email || !password) {
      throw new Error('TEST_USER_EMAIL and TEST_USER_PASSWORD must be set');
    }

    await use({ email, password });
  },

  apiToken: async ({}, use) => {
    const token = process.env.API_TOKEN;
    if (!token) throw new Error('API_TOKEN must be set');
    await use(token);
  },
});

Tests use the fixture, not process.env directly:

import { test } from '../fixtures/env';
import { expect } from '@playwright/test';

test('user can log in', async ({ page, testUser }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(testUser.email);
  await page.getByLabel('Password').fill(testUser.password);
  await page.getByRole('button', { name: 'Log in' }).click();
  await expect(page).toHaveURL('/dashboard');
});

The benefit: if the environment variable is missing, you get a clear error at the fixture level instead of a confusing failure mid-test.

CI/CD environment variables

In GitHub Actions, set secrets in repository settings, then pass them to tests:

# .github/workflows/tests.yml
jobs:
  test:
    runs-on: ubuntu-latest
    env:
      BASE_URL: ${{ vars.STAGING_URL }}
      API_TOKEN: ${{ secrets.API_TOKEN }}
      TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
      TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test

Use vars (not secrets) for non-sensitive values like the staging URL: they're visible in CI logs, which helps debugging. Use secrets for credentials, tokens, and passwords.

Environment-specific test data

Some tests should only run in certain environments. Use tags and environment checks:

test('payment flow smoke test', {
  tag: '@smoke',
  annotation: { type: 'env', description: 'staging and production only' },
}, async ({ page }) => {
  // Skip in local development to avoid hitting payment sandbox
  test.skip(
    process.env.BASE_URL?.includes('localhost') === true,
    'Payment tests require staging or production environment'
  );

  // test body...
});

Or use a fixture that resolves the current environment:

const currentEnv = process.env.BASE_URL?.includes('staging')
  ? 'staging'
  : process.env.BASE_URL?.includes('localhost')
  ? 'local'
  : 'production';

This avoids hardcoded if (process.env.BASE_URL === '...') checks scattered across test files.

What goes in environment variables vs config file

| Configuration | Where it belongs |

|---|---|

| Base URL | Environment variable |

| API tokens, passwords | Environment variable (secret) |

| Browser type | Config file |

| Retry count | Config file (per environment if needed) |

| Timeout values | Config file |

| Test data seeds | Fixtures |

| Feature flags for test behavior | Environment variable |

The rule: anything that differs between environments is an environment variable. Anything that's a stable test framework setting is in the config file.

→ See also: Playwright Config File Explained: Every Option You Need to Know | Environment Variables in Playwright Tests: A Complete Guide | GitHub Actions for Playwright Tests: The Complete Setup (2026)