Q45 of 48 · Cypress

How do you handle environment-specific configuration in Cypress (dev/staging/prod)?

CypressSeniorcypressconfigurationenvironmentssecretssenior

Short answer

Short answer: Use a single `cypress.config.ts` plus per-env overrides via env vars (`CYPRESS_baseUrl`, `CYPRESS_env_*`) or a small env-specific config loader in `setupNodeEvents`. Avoid one config file per environment — it diverges over time. Secrets come from CI vars, not committed files.

Detail

The two anti-patterns to avoid:

  • Hard-coded URLs in specs. cy.visit('https://staging.example.com/login') — spec works in one env only.
  • Multiple full config files. cypress.dev.config.ts, cypress.staging.config.ts — they diverge over time, and the differences become hard to track.

The clean approach is one config file with environment-driven overrides:

// cypress.config.ts
export default defineConfig({
  e2e: {
    baseUrl: process.env.CYPRESS_BASE_URL ?? 'http://localhost:3000',
    setupNodeEvents(on, config) {
      const env = process.env.CYPRESS_TARGET_ENV ?? 'dev';
      // Load env-specific extras from a small JSON file
      const envConfig = require(`./cypress.env.${env}.json`);
      config.env = { ...config.env, ...envConfig };
      return config;
    },
  },
  env: {
    apiUrl: process.env.CYPRESS_API_URL ?? 'http://localhost:4000',
  },
});

The env-specific JSONs hold non-secret per-env values (feature flags to expect, third-party sandbox URLs):

// cypress.env.staging.json
{
  "featureXEnabled": true,
  "stripePublicKey": "pk_test_..."
}

Secrets (passwords, API keys for test users) come from CI env vars, never committed:

CYPRESS_password=$STAGING_TEST_PASSWORD npx cypress run

Anything prefixed CYPRESS_ lands in Cypress.env(...) automatically. Anything else is just a Node env var available in setupNodeEvents.

For per-env behaviour in specs:

const env = Cypress.env('targetEnv') ?? 'dev';

it('handles a feature that only exists in staging+', () => {
  if (env === 'dev') return;
  // ...
});

Better: tag specs and skip via @cypress/grep so the conditional logic isn't inside the spec.

Production testing is its own conversation. Most teams don't run E2E against prod because the side effects (test orders, fake users polluting analytics) are dangerous. If you must, use a dedicated test tenant + read-only smoke tests + clear data isolation.

// EXAMPLE

.github/workflows/cypress-staging.yml

name: cypress-staging
on: [push]
jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npx cypress run
        env:
          CYPRESS_BASE_URL: https://staging.example.com
          CYPRESS_API_URL: https://api-staging.example.com
          CYPRESS_TARGET_ENV: staging
          CYPRESS_password: ${{ secrets.STAGING_TEST_PASSWORD }}
          CYPRESS_adminEmail: admin+e2e@example.com
          CYPRESS_viewerEmail: viewer+e2e@example.com

// WHAT INTERVIEWERS LOOK FOR

Single config + env vars (not multiple config files), separating secrets (CI vars) from non-secrets (per-env JSON), and the senior caveat about prod testing being a different conversation.

// COMMON PITFALL

Maintaining `cypress.dev.config.ts` / `cypress.staging.config.ts` / `cypress.prod.config.ts` and watching them diverge over six months.