Multi Environment Config
Why This Matters
Production Playwright test suites run against multiple environments with different base URLs, authentication endpoints, API servers, and database configurations. Development environments use localhost, staging environments mirror production infrastructure with test data, and production environments serve real users with live data. Hardcoding environment-specific values creates brittle tests that fail when promoted across environments and require code changes for each deployment.
Traditional test automation approaches embed environment configuration in test code through string literals, making environment changes require code modifications, pull requests, and redeploys. This creates friction for QA teams who need to run tests against ephemeral preview environments, developers who switch between local and cloud environments, and DevOps teams who deploy to multiple regions. Environment-specific failures become difficult to reproduce because test behavior depends on hardcoded values that don’t match the target environment.
Production-grade environment configuration externalizes all environment-specific values into configuration files, environment variables, and secure secret stores. Tests reference logical configuration keys rather than literal values, enabling the same test code to execute against any environment by simply changing configuration. This separation of concerns allows QA engineers to manage test environments independently from developers, enables CI/CD pipelines to inject environment-specific values at runtime, and supports secure secret management through tools like HashiCorp Vault, AWS Secrets Manager, or Azure Key Vault.
Standard Library Approach: Environment Variables and TypeScript Process.env
Node.js provides built-in environment variable access through process.env without requiring external dependencies.
// playwright.config.ts
// => Playwright configuration reading environment variables
import { defineConfig, devices } from "@playwright/test";
// => defineConfig provides type-safe configuration
// => devices contains browser viewport presets
const baseURL = process.env.BASE_URL || "http://localhost:3000";
// => Reads BASE_URL from environment variables
// => Falls back to localhost if not set
// => process.env is Node.js standard library (no imports)
// => Populated from shell environment or .env files
const apiURL = process.env.API_URL || "http://localhost:8080";
// => API server URL for backend requests
// => Different from frontend BASE_URL in microservices
// => Falls back to local development server
export default defineConfig({
// => Root configuration object
testDir: "./tests",
// => Directory containing test files
use: {
// => Global test execution options
baseURL: baseURL,
// => All page.goto() calls resolve relative to baseURL
// => page.goto("/login") becomes http://localhost:3000/login
// => Eliminates hardcoded URLs in test code
extraHTTPHeaders: {
// => Headers added to all requests
"X-API-URL": apiURL,
// => Custom header passing API URL to tests
// => Tests access via page.request.context()
},
},
projects: [
// => Browser configurations for parallel execution
{
name: "chromium",
// => Project identifier for this browser
use: { ...devices["Desktop Chrome"] },
// => Spreads Desktop Chrome viewport and user agent
},
],
});// tests/auth.spec.ts
// => Authentication test using environment configuration
import { test, expect } from "@playwright/test";
// => test provides test runner, expect provides assertions
const username = process.env.TEST_USERNAME || "testuser";
// => Test credentials from environment variables
// => Falls back to default for local development
// => Keeps credentials out of source control
const password = process.env.TEST_PASSWORD || "testpass";
// => Password from environment variables
// => Never committed to repository
// => CI injects from secret store
test("user logs in successfully", async ({ page }) => {
// => Async test function receives page fixture
// => page inherits baseURL from config
await page.goto("/login");
// => Navigates to baseURL + "/login"
// => Resolves to http://localhost:3000/login locally
// => Resolves to https://staging.example.com/login in staging
// => No hardcoded URLs in test
await page.fill("#username", username);
// => Fills username from environment variable
// => Different credentials per environment
// => Local dev uses testuser, production uses real account
await page.fill("#password", password);
// => Fills password from environment variable
// => Sensitive data never in source code
await page.click("button[type=submit]");
// => Submits login form
// => Clicks first submit button
await expect(page).toHaveURL("/dashboard");
// => Verifies redirect to dashboard
// => Relative URL resolves to baseURL + "/dashboard"
// => Works across all environments
});# .env.development
# => Development environment configuration
# => NOT committed to git (in .gitignore)
# => Local developers create from .env.example template
BASE_URL=http://localhost:3000
# => Local development server
# => Next.js dev server on port 3000
API_URL=http://localhost:8080
# => Local API server
# => Spring Boot dev server on port 8080
TEST_USERNAME=dev@example.com
# => Development test account
# => Safe to use default credentials locally
TEST_PASSWORD=devpassword
# => Development password
# => Not sensitive (local only)# Running tests with environment configuration
npx playwright test
# => Uses .env.development by default (if dotenv configured)
# => Falls back to hardcoded defaults in config
BASE_URL=https://staging.example.com npx playwright test
# => Override environment variable at runtime
# => Staging environment uses production-like infrastructure
# => No code changes required for different environmentLimitations for production:
- No automatic .env file loading: Node.js doesn’t load .env files by default (requires manual fs.readFileSync or dotenv library)
- No environment hierarchy: Cannot inherit staging config from production with overrides (flat key-value pairs only)
- No type safety: process.env values are always strings (no validation, no TypeScript types)
- No secret encryption: .env files stored as plaintext (dangerous if committed accidentally)
- No secret rotation: Changing secrets requires redeploying configuration files (no dynamic loading)
- No secret auditing: No visibility into who accessed secrets or when (compliance issue)
- No config validation: Typos in environment variable names fail silently (returns undefined)
- Manual synchronization: Keeping .env.development, .env.staging, .env.production in sync requires manual effort
Production Framework: dotenv-vault with Configuration Inheritance
Production environment management requires encrypted secrets, configuration inheritance, and automated synchronization across environments.
Installation and Setup
# Install dotenv-vault for production secret management
npm install --save-dev dotenv-vault
# => dotenv-vault extends dotenv with encryption and inheritance
# => Manages .env files with encryption at rest
# => Supports environment-specific overrides
# => Size: ~100KB (includes crypto libraries)# Initialize vault
npx dotenv-vault new
# => Creates .env.vault for encrypted secrets
# => Generates DOTENV_KEY for decryption
# => .env.vault committed to git (encrypted)
# => DOTENV_KEY stored in CI/CD secrets (never committed)// playwright.config.ts
// => Production configuration with dotenv-vault
import { defineConfig, devices } from "@playwright/test";
// => defineConfig provides type-safe configuration
import * as dotenv from "dotenv";
// => dotenv for .env file parsing
import * as dotenvVault from "dotenv-vault-core";
// => dotenv-vault for encrypted secret management
// => Extends dotenv with .env.vault decryption
// Load environment configuration
dotenv.config({ path: `.env.${process.env.NODE_ENV || "development"}` });
// => Loads environment-specific .env file
// => .env.development, .env.staging, .env.production
// => NODE_ENV determines which file to load
// => Falls back to development if NODE_ENV not set
dotenvVault.config();
// => Decrypts .env.vault using DOTENV_KEY
// => Overrides .env values with encrypted vault values
// => Vault values take precedence over plaintext .env
// => DOTENV_KEY injected by CI/CD from secret store
// Configuration interface for type safety
interface EnvironmentConfig {
// => Type-safe configuration object
// => Prevents typos and missing values
baseURL: string;
// => Base URL for web application
apiURL: string;
// => API server URL
authURL: string;
// => Authentication service URL (OAuth, OIDC)
credentials: {
// => Nested configuration object
username: string;
// => Test account username
password: string;
// => Test account password (from vault)
apiKey: string;
// => API key for backend requests (from vault)
};
timeouts: {
// => Environment-specific timeout values
navigation: number;
// => Page navigation timeout in milliseconds
action: number;
// => Action timeout for clicks, fills
};
retry: {
// => Retry configuration per environment
maxRetries: number;
// => Max test retries on failure
retryDelay: number;
// => Delay between retries in milliseconds
};
}
// Load and validate environment configuration
function loadEnvironmentConfig(): EnvironmentConfig {
// => Factory function for type-safe config loading
// => Validates required variables exist
// => Throws error if misconfigured
const requiredVars = ["BASE_URL", "API_URL", "AUTH_URL", "TEST_USERNAME", "TEST_PASSWORD", "API_KEY"];
// => List of required environment variables
// => Configuration validation at startup
for (const varName of requiredVars) {
// => Iterate required variables
if (!process.env[varName]) {
// => Check if variable defined
throw new Error(
`Missing required environment variable: ${varName}. ` +
`Check .env.${process.env.NODE_ENV || "development"} file.`,
);
// => Fails fast with clear error message
// => Prevents tests running with missing config
// => Error message includes environment name
}
}
return {
// => Returns validated configuration object
baseURL: process.env.BASE_URL!,
// => Non-null assertion (validated above)
apiURL: process.env.API_URL!,
authURL: process.env.AUTH_URL!,
credentials: {
username: process.env.TEST_USERNAME!,
password: process.env.TEST_PASSWORD!,
// => Decrypted from .env.vault by dotenv-vault
apiKey: process.env.API_KEY!,
// => API key never in plaintext (vault only)
},
timeouts: {
navigation: parseInt(process.env.NAVIGATION_TIMEOUT || "30000", 10),
// => Parses string to number with default 30 seconds
// => Different timeout per environment (staging slower)
action: parseInt(process.env.ACTION_TIMEOUT || "10000", 10),
// => Action timeout defaults to 10 seconds
},
retry: {
maxRetries: parseInt(process.env.MAX_RETRIES || "2", 10),
// => Production uses more retries for flaky networks
retryDelay: parseInt(process.env.RETRY_DELAY || "1000", 10),
// => Delay between retries (exponential backoff possible)
},
};
// => Type-safe config object with validated values
}
const config = loadEnvironmentConfig();
// => Loads configuration at module initialization
// => Fails fast if misconfigured (before running tests)
export default defineConfig({
// => Root Playwright configuration object
testDir: "./tests",
// => Directory containing test files
timeout: config.timeouts.navigation,
// => Global test timeout from environment config
// => Staging environment may have higher timeout
retries: config.retry.maxRetries,
// => Retry failed tests based on environment
// => Production CI uses more retries than local
use: {
// => Global test execution options
baseURL: config.baseURL,
// => All page.goto() calls resolve relative to baseURL
// => Centralized URL configuration
extraHTTPHeaders: {
// => Headers added to all requests
"X-API-Key": config.credentials.apiKey,
// => API key from encrypted vault
// => Never in plaintext in repository
},
actionTimeout: config.timeouts.action,
// => Timeout for individual actions (click, fill)
// => Environment-specific timeout tuning
navigationTimeout: config.timeouts.navigation,
// => Timeout for page navigation
// => Staging may be slower than production
},
projects: [
// => Browser configurations
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
// Export config for test access
export { config };
// => Tests import config for environment-specific behavior
// tests/auth.spec.ts
// => Authentication test using production configuration
import { test, expect } from "@playwright/test";
// => test provides test runner, expect provides assertions
import { config } from "../playwright.config";
// => Imports centralized environment configuration
// => Type-safe access to configuration values
test("user logs in successfully", async ({ page }) => {
// => Async test function receives page fixture
// => page inherits baseURL from config
await page.goto("/login");
// => Navigates to baseURL + "/login"
// => baseURL from environment configuration
// => Resolves to correct environment (dev/staging/prod)
await page.fill("#username", config.credentials.username);
// => Fills username from type-safe config
// => Different credentials per environment
// => No process.env direct access in tests
await page.fill("#password", config.credentials.password);
// => Fills password from encrypted vault
// => Decrypted by dotenv-vault at startup
// => Password never in plaintext in repository
await page.click("button[type=submit]");
// => Submits login form
await expect(page).toHaveURL("/dashboard");
// => Verifies redirect to dashboard
// => Relative URL resolves correctly
});
test("API authentication works", async ({ request }) => {
// => API testing using request fixture
// => request context inherits headers from config
const response = await request.get(`${config.apiURL}/user/profile`);
// => GET request to API server
// => apiURL from environment configuration
// => X-API-Key header included automatically
expect(response.ok()).toBeTruthy();
// => Verifies 2xx status code
// => API key authentication succeeded
const data = await response.json();
// => Parses JSON response
expect(data.username).toBe(config.credentials.username);
// => Verifies profile matches authenticated user
// => Configuration used consistently across tests
});# .env.development
# => Development environment configuration (plaintext)
# => Safe for local development
# => NOT containing sensitive production secrets
BASE_URL=http://localhost:3000
API_URL=http://localhost:8080
AUTH_URL=http://localhost:9000
TEST_USERNAME=dev@example.com
TEST_PASSWORD=devpassword
API_KEY=dev-api-key-12345
NAVIGATION_TIMEOUT=30000
ACTION_TIMEOUT=10000
MAX_RETRIES=1
RETRY_DELAY=1000# .env.staging
# => Staging environment configuration (plaintext)
# => Inherits from development with overrides
# => Production-like infrastructure with test data
BASE_URL=https://staging.example.com
API_URL=https://api-staging.example.com
AUTH_URL=https://auth-staging.example.com
TEST_USERNAME=staging@example.com
# TEST_PASSWORD stored in .env.vault (encrypted)
# API_KEY stored in .env.vault (encrypted)
NAVIGATION_TIMEOUT=45000
ACTION_TIMEOUT=15000
MAX_RETRIES=2
RETRY_DELAY=2000# .env.production
# => Production environment configuration
# => Only non-sensitive values in plaintext
# => All secrets in .env.vault
BASE_URL=https://example.com
API_URL=https://api.example.com
AUTH_URL=https://auth.example.com
TEST_USERNAME=production-test@example.com
# TEST_PASSWORD in vault only (NEVER plaintext)
# API_KEY in vault only (NEVER plaintext)
NAVIGATION_TIMEOUT=60000
ACTION_TIMEOUT=20000
MAX_RETRIES=3
RETRY_DELAY=3000# .env.vault
# => Encrypted secrets file (committed to git)
# => Contains encrypted values for all environments
# => Decrypted using DOTENV_KEY (stored in CI/CD secrets)
#/-------------------.env.vault---------------------/
#/ cloud-agnostic vaulting standard /
#/ [how it works](https://dotenv.org/env-vault) /
#/--------------------------------------------------/
# development
DOTENV_VAULT_DEVELOPMENT="encrypted_development_secrets_here"
# staging
DOTENV_VAULT_STAGING="encrypted_staging_secrets_here"
# production
DOTENV_VAULT_PRODUCTION="encrypted_production_secrets_here"# CI/CD Pipeline (GitHub Actions)
# => .github/workflows/test.yml
# => Production test execution in CI
name: Playwright Tests
on:
push:
branches: [main, staging, production]
env:
NODE_ENV: staging
# => Loads .env.staging configuration
# => Change to 'production' for prod tests
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# => Checks out repository code
- uses: actions/setup-node@v3
with:
node-version: 18
# => Node.js 18 LTS
- name: Install dependencies
run: npm ci
# => Clean install (uses package-lock.json)
- name: Run Playwright tests
env:
DOTENV_KEY: ${{ secrets.DOTENV_KEY_STAGING }}
# => Decryption key from GitHub Secrets
# => Never committed to repository
# => Rotated periodically for security
run: npx playwright test
# => Runs tests against staging environment
# => dotenv-vault decrypts secrets using DOTENV_KEY
# => Tests execute with staging configurationProduction Pattern Diagram
graph TB
subgraph "Environment Configuration Hierarchy"
A[".env.example<br/>(Template)"]
B[".env.development<br/>(Local)"]
C[".env.staging<br/>(Cloud Test)"]
D[".env.production<br/>(Live)"]
E[".env.vault<br/>(Encrypted Secrets)"]
end
subgraph "Configuration Loading"
F["playwright.config.ts<br/>(Config Loader)"]
G["loadEnvironmentConfig()<br/>(Validation)"]
H["EnvironmentConfig<br/>(Type-Safe Object)"]
end
subgraph "Runtime Injection"
I["CI/CD Secrets<br/>(DOTENV_KEY)"]
J["dotenv-vault<br/>(Decryption)"]
K["process.env<br/>(Runtime Values)"]
end
subgraph "Test Execution"
L["tests/*.spec.ts<br/>(Test Files)"]
M["config.baseURL<br/>(Environment URL)"]
N["config.credentials<br/>(Decrypted Secrets)"]
end
A -->|"Copy to create"| B
A -->|"Copy to create"| C
A -->|"Copy to create"| D
B --> F
C --> F
D --> F
E --> J
I --> J
J --> K
K --> F
F --> G
G --> H
H --> M
H --> N
M --> L
N --> L
style A fill:#029E73
style B fill:#0173B2
style C fill:#DE8F05
style D fill:#CC78BC
style E fill:#CA9161
style F fill:#029E73
style G fill:#0173B2
style H fill:#DE8F05
style I fill:#CC78BC
style J fill:#CA9161
style K fill:#029E73
style L fill:#0173B2
style M fill:#DE8F05
style N fill:#CC78BC
Diagram explanation:
- Configuration Hierarchy: Template → environment-specific files → encrypted vault
- Configuration Loading: TypeScript config loader validates and creates type-safe object
- Runtime Injection: CI/CD injects decryption key, dotenv-vault decrypts secrets
- Test Execution: Tests use centralized config object for environment-specific behavior
Production Patterns and Best Practices
Pattern 1: Environment Variable Validation
Validate configuration at startup to fail fast on misconfiguration.
// lib/config-validator.ts
// => Configuration validation utilities
import * as z from "zod";
// => Zod for runtime type validation
// => npm install zod (size: ~50KB)
const EnvironmentSchema = z.object({
// => Zod schema for environment configuration
// => Validates types and constraints
baseURL: z.string().url(),
// => Must be valid URL format
// => Zod validates at runtime
apiURL: z.string().url(),
authURL: z.string().url(),
credentials: z.object({
username: z.string().email(),
// => Must be valid email format
password: z.string().min(8),
// => Password minimum length validation
apiKey: z.string().regex(/^[A-Za-z0-9-_]{20,}$/),
// => API key format validation (20+ alphanumeric)
}),
timeouts: z.object({
navigation: z.number().min(1000).max(120000),
// => Navigation timeout between 1s and 2min
action: z.number().min(500).max(60000),
// => Action timeout between 0.5s and 1min
}),
retry: z.object({
maxRetries: z.number().min(0).max(10),
// => Max retries between 0 and 10
retryDelay: z.number().min(0).max(30000),
// => Retry delay between 0 and 30 seconds
}),
});
// => Type-safe validation schema
export function validateEnvironmentConfig(config: unknown): EnvironmentConfig {
// => Validates unknown object against schema
// => Returns validated type-safe config
try {
return EnvironmentSchema.parse(config);
// => Zod validates and returns typed object
// => Throws ZodError if validation fails
} catch (error) {
// => Catches validation errors
if (error instanceof z.ZodError) {
// => Zod validation error
const messages = error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join("\n");
// => Formats validation errors with field paths
throw new Error(`Environment configuration validation failed:\n${messages}`);
// => Clear error message with all validation failures
}
throw error;
// => Re-throw unexpected errors
}
}Pattern 2: Configuration Inheritance
Implement configuration inheritance for DRY environment management.
// lib/config-inheritance.ts
// => Configuration inheritance for environment overrides
import * as fs from "fs";
// => Node.js filesystem module (standard library)
import * as path from "path";
// => Node.js path module for file paths
import * as dotenv from "dotenv";
// => dotenv for .env file parsing
interface ConfigInheritance {
// => Configuration inheritance interface
base?: string;
// => Base configuration file to inherit from
overrides: Record<string, string>;
// => Key-value overrides for this environment
}
export function loadConfigWithInheritance(environment: string): Record<string, string> {
// => Loads configuration with inheritance support
// => Returns merged configuration object
const configPath = path.join(process.cwd(), `.env.${environment}`);
// => Path to environment-specific .env file
// => Example: .env.staging
const parsed = dotenv.parse(fs.readFileSync(configPath));
// => Parses .env file into key-value object
// => Throws if file doesn't exist
const inheritancePath = path.join(process.cwd(), `.env.${environment}.inheritance.json`);
// => Path to inheritance metadata file
// => Example: .env.staging.inheritance.json
if (!fs.existsSync(inheritancePath)) {
// => No inheritance metadata, return parsed config
return parsed;
}
const inheritance: ConfigInheritance = JSON.parse(fs.readFileSync(inheritancePath, "utf-8"));
// => Loads inheritance configuration
// => Specifies base environment to inherit from
let baseConfig: Record<string, string> = {};
if (inheritance.base) {
// => If base environment specified
baseConfig = loadConfigWithInheritance(inheritance.base);
// => Recursively load base configuration
// => Supports multi-level inheritance
}
return {
...baseConfig,
// => Spread base configuration (inherited values)
...parsed,
// => Override with environment-specific values
// => Later values override earlier values
};
// => Returns merged configuration object
}// .env.staging.inheritance.json
// => Staging inherits from development
{
"base": "development",
"overrides": {
"BASE_URL": "https://staging.example.com",
"API_URL": "https://api-staging.example.com"
}
}Pattern 3: Secrets Management Integration
Integrate with cloud secret managers for production secrets.
// lib/secrets-manager.ts
// => Cloud secrets manager integration
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
// => AWS SDK for Secrets Manager
// => npm install @aws-sdk/client-secrets-manager
const client = new SecretsManagerClient({
// => AWS Secrets Manager client
region: process.env.AWS_REGION || "us-east-1",
// => AWS region from environment
});
export async function loadSecretsFromAWS(secretName: string): Promise<Record<string, string>> {
// => Loads secrets from AWS Secrets Manager
// => Returns key-value object of secrets
try {
const command = new GetSecretValueCommand({ SecretId: secretName });
// => AWS Secrets Manager command to retrieve secret
// => secretName is ARN or friendly name
const response = await client.send(command);
// => Sends command to AWS Secrets Manager
// => Returns secret value (string or binary)
if (!response.SecretString) {
// => Secret not found or binary format
throw new Error("Secret not found or wrong format");
}
return JSON.parse(response.SecretString);
// => Parses JSON secret into object
// => Example: {"API_KEY": "secret", "DB_PASSWORD": "pass"}
} catch (error) {
// => Catches AWS errors
console.error(`Failed to load secrets: ${error}`);
// => Logs error for debugging
throw error;
// => Re-throw for caller handling
}
}
export async function loadEnvironmentWithSecrets(environment: string): Promise<void> {
// => Loads environment config with AWS secrets
// => Merges secrets into process.env
dotenv.config({ path: `.env.${environment}` });
// => Loads base environment configuration
const secretName = process.env.AWS_SECRET_NAME;
// => Secret name from environment variable
// => Example: playwright-staging-secrets
if (!secretName) {
// => No AWS secret configured
console.warn("AWS_SECRET_NAME not configured, skipping secrets load");
return;
}
const secrets = await loadSecretsFromAWS(secretName);
// => Loads secrets from AWS Secrets Manager
// => Returns key-value object
Object.assign(process.env, secrets);
// => Merges secrets into process.env
// => Overrides .env values with AWS secrets
// => Secrets take precedence over local .env
}Trade-offs and When to Use
Standard Approach (process.env with .env files):
- Use when: Small projects, single environment, no sensitive secrets
- Benefits: Zero dependencies, simple setup, fast configuration
- Costs: No encryption, no validation, no inheritance, manual synchronization
Production Framework (dotenv-vault with validation):
- Use when: Multiple environments, sensitive secrets, team collaboration, CI/CD pipelines
- Benefits: Encrypted secrets, configuration inheritance, type safety, automated synchronization
- Costs: External dependency (dotenv-vault), learning curve, vault management overhead
Production recommendation: Use dotenv-vault for all projects beyond local development. The security benefits of encrypted secrets, combined with configuration validation and inheritance, justify the minimal overhead. Start with basic .env files locally, then migrate to dotenv-vault before deploying to staging or production. For enterprise systems, integrate with cloud secret managers (AWS Secrets Manager, Azure Key Vault) for centralized secret rotation and auditing.
Security Considerations
Secret Management Best Practices
- Never commit secrets to git: Add
.env.*(except.env.example) to.gitignore - Use encrypted vault: Store production secrets in
.env.vaultwith encryption - Rotate secrets regularly: Update
DOTENV_KEYand re-encrypt vault quarterly - Principle of least privilege: Grant CI/CD only access to required secrets (staging vs production)
- Audit secret access: Use cloud secret managers with audit logging for compliance
- Separate test accounts: Never use production credentials in test environments
Environment Variable Security
// Sanitize logs to prevent secret leaks
function sanitizeConfig(config: EnvironmentConfig): string {
// => Sanitizes configuration for logging
// => Removes sensitive values
const sanitized = {
...config,
credentials: {
username: config.credentials.username,
password: "***REDACTED***",
// => Masks password in logs
apiKey: "***REDACTED***",
// => Masks API key in logs
},
};
return JSON.stringify(sanitized, null, 2);
// => Returns JSON string with masked secrets
}
console.log("Loaded configuration:", sanitizeConfig(config));
// => Safe logging without exposing secrets
Vault Key Management
# Store DOTENV_KEY in CI/CD secrets (GitHub Actions)
# Never commit DOTENV_KEY to repository
# Rotate keys quarterly
# GitHub Actions Secret
DOTENV_KEY_DEVELOPMENT=dotenv://:key_1234@dotenv.org/vault/.env.vault?environment=development
DOTENV_KEY_STAGING=dotenv://:key_5678@dotenv.org/vault/.env.vault?environment=staging
DOTENV_KEY_PRODUCTION=dotenv://:key_9012@dotenv.org/vault/.env.vault?environment=productionCommon Pitfalls
Pitfall 1: Hardcoding environment values in test code
Wrong:
// DON'T: Hardcoded URL in test
await page.goto("https://staging.example.com/login");
// => Breaks when promoting tests to production
Right:
// DO: Use centralized configuration
await page.goto("/login");
// => Resolves to baseURL from environment config
Pitfall 2: Committing .env files with secrets
Wrong:
# .gitignore missing .env files
# Result: Production secrets committed to gitRight:
# .gitignore properly configured
.env
.env.*
!.env.example
!.env.vault
# => Only .env.example and .env.vault committedPitfall 3: No configuration validation
Wrong:
// DON'T: No validation, fails at runtime
const baseURL = process.env.BASE_URL;
await page.goto(baseURL + "/login");
// => TypeError: Cannot read property '+' of undefined
Right:
// DO: Validate configuration at startup
const config = validateEnvironmentConfig(loadConfig());
// => Fails fast with clear error message
await page.goto("/login");
// => Guaranteed config.baseURL exists
Pitfall 4: Using same secrets across environments
Wrong:
# Same API key for staging and production
API_KEY=prod-key-12345 # Both environments
# => Production compromise affects stagingRight:
# .env.staging
API_KEY=staging-key-12345
# .env.production
API_KEY=prod-key-67890
# => Environment isolation prevents cross-contaminationPitfall 5: No secret rotation strategy
Wrong:
# Secrets never rotated
# Same DOTENV_KEY for 2+ years
# => Increased exposure window if compromisedRight:
# Rotate secrets quarterly
npx dotenv-vault keys rotate
# => Generates new DOTENV_KEY
# => Re-encrypts vault with new key
# => Updates CI/CD secrets