Environment Variables and Configuration

8 min read

A test suite that runs only against http://localhost:3000 is a toy. Real teams point the same specs at four environments — local, dev, staging, prod — and at three browsers, with different credentials per environment, different timeouts in CI, and different retry counts when a build is shaky. Cypress handles all of that through cypress.config.ts, cypress.env.json, and CLI flags layered on top of each other. Once you understand the precedence rules, environment switching becomes one command-line argument.

The two configuration surfaces

Cypress separates configuration (how the runner behaves: viewport, timeouts, base URL) from environment variables (values your tests reference: API URLs, credentials, feature-flag names). Both live in similar-looking files but serve different roles.

// cypress.config.ts — top-level runner configuration
import { defineConfig } from "cypress";
 
export default defineConfig({
  e2e: {
    baseUrl: "http://localhost:3000",
    viewportWidth: 1280,
    viewportHeight: 720,
    defaultCommandTimeout: 4000,
    env: {
      apiUrl: "http://localhost:3000/api",
      adminEmail: "admin@test.com",
    },
  },
});

The top-level keys (baseUrl, viewportWidth, etc.) are configuration. The env block is where test-visible values go.

In a spec:

const apiUrl = Cypress.env("apiUrl");        // → "http://localhost:3000/api"
const admin = Cypress.env("adminEmail");      // → "admin@test.com"
 
cy.request("GET", `${apiUrl}/users`);
cy.get("[data-testid='email']").type(admin);

Cypress.env(key) returns whatever's been resolved for that key by the time the test runs.

cypress.env.json — the local override

cypress.env.json sits next to cypress.config.ts and overrides values for this machine. It's typically gitignored so each developer can keep their own credentials and staging URLs locally:

{
  "apiUrl": "https://staging-api.myapp.com",
  "adminPassword": "StagingSecret123",
  "stripeKey": "sk_test_abc..."
}

When a test calls Cypress.env("apiUrl"), the value from cypress.env.json wins over the default in cypress.config.ts. This is the right place for:

  • Per-developer staging URLs and credentials.
  • Test-only API keys (Stripe test secrets, third-party tokens).
  • Local overrides for CI-only env vars during debugging.

Add cypress.env.json to .gitignore on day one. Commit a cypress.env.example.json template with placeholder values so new engineers know which keys to populate.

CLI overrides — the highest-priority surface

Pass env vars on the command line for one-off runs:

npx cypress run --env apiUrl=https://prod-api.myapp.com,adminEmail=qa@myapp.com

CLI values override both cypress.env.json and cypress.config.ts. CI pipelines use this to inject environment-specific values without committing them anywhere:

# In a GitHub Actions step
npx cypress run --env apiUrl=$STAGING_API_URL,adminPassword=$STAGING_ADMIN_PASS

Where $STAGING_API_URL and $STAGING_ADMIN_PASS come from CI secrets. Tests don't need to know whether a value came from a local file or a CI secret — Cypress.env(...) is the same API either way.

System environment variables prefixed CYPRESS_*

Any shell env var that starts with CYPRESS_ is automatically picked up:

export CYPRESS_apiUrl=https://staging-api.myapp.com
export CYPRESS_adminPassword=StagingSecret123
npx cypress run

Inside the spec, Cypress.env("apiUrl") resolves to the shell value. This is useful for one-shot runs where editing cypress.env.json is overkill — and for CI systems that prefer env-var injection over CLI flags.

The precedence chain

When Cypress.env("apiUrl") runs, Cypress checks sources in this order. The first one that has a value wins:

Knowing the chain means you can answer "where did this credential come from?" in a debugging session by inspecting each layer in turn.

Multi-environment configuration in one file

For a project that targets dev, staging, and prod, branch in cypress.config.ts:

import { defineConfig } from "cypress";
 
const envConfigs = {
  dev: {
    baseUrl: "http://localhost:3000",
    apiUrl: "http://localhost:3000/api",
  },
  staging: {
    baseUrl: "https://staging.myapp.com",
    apiUrl: "https://staging-api.myapp.com",
  },
  production: {
    baseUrl: "https://myapp.com",
    apiUrl: "https://api.myapp.com",
  },
} as const;
 
const target = (process.env.CYPRESS_TARGET ?? "dev") as keyof typeof envConfigs;
const active = envConfigs[target];
 
export default defineConfig({
  e2e: {
    baseUrl: active.baseUrl,
    env: {
      apiUrl: active.apiUrl,
      target,
    },
  },
});

Now CYPRESS_TARGET=staging npx cypress run swaps the entire baseUrl and API URL atomically. CI workflows pass CYPRESS_TARGET per environment; local devs pass it occasionally to verify against staging without changing files.

Configuration options worth knowing

A few of the dozens of config keys you'll touch in real projects:

  • viewportWidth / viewportHeight — default browser size during tests. The default (1000 × 660) is small for modern apps; most teams set 1280 × 720 or 1440 × 900 to match desktop production users.
  • defaultCommandTimeout — how long retry-able commands wait. Default 4000ms. Increase only if the app is genuinely slow.
  • requestTimeout / responseTimeout — separate timeouts for cy.request and cy.intercept. Default 5000ms.
  • pageLoadTimeout — how long cy.visit waits for the load event. Default 60_000ms (60s) — rarely changed.
  • video — record an mp4 of every spec in headless mode. Default true. Set to false if you don't need videos and want to save CI artifact space; pair with the recipe in the Test Runner lesson to keep videos only on failure.
  • screenshotOnRunFailure — capture a screenshot when a test fails. Default true. Almost always keep on.
  • retries — auto-retry failing tests. { runMode: 2, openMode: 0 } retries each test up to two more times in headless CI mode and never in interactive mode. Use sparingly: it's a band-aid for flake, not a cure. Chapter 8 returns to retries in the CI/CD lesson.
  • watchForFileChanges — re-run specs when the file changes. Default true in cypress open, false in cypress run.

The full reference is in Cypress's docs and on the Cypress commands cheat sheet.

Secrets — what not to do

A category of mistakes worth flagging explicitly:

  • Don't commit production credentials. Anything in cypress.config.ts or cypress/fixtures/ is in git. Treat it as public.
  • Don't put secrets in CLI flags that end up in shell history. Use environment variables sourced from a .env file (gitignored) or a CI secret store.
  • Don't expose long-lived production tokens to test runs. A test suite shouldn't be able to mutate production. Use test-tier accounts and test-tier API keys.
  • Don't reuse the same admin password across dev/staging/prod. A leaked staging password should never grant prod access.

If a test needs a secret, the path is: CI secret → injected as CYPRESS_<KEY> env var → Cypress.env("KEY") in the test. No file commit, no console log.

A real cypress.config.ts for a typed project

Bringing every concept together:

import { defineConfig } from "cypress";
 
const targets = {
  dev:        { baseUrl: "http://localhost:3000", apiUrl: "http://localhost:3000/api" },
  staging:    { baseUrl: "https://staging.myapp.com", apiUrl: "https://staging-api.myapp.com" },
  production: { baseUrl: "https://myapp.com", apiUrl: "https://api.myapp.com" },
} as const;
 
const target = (process.env.CYPRESS_TARGET ?? "dev") as keyof typeof targets;
const active = targets[target];
 
export default defineConfig({
  e2e: {
    baseUrl: active.baseUrl,
    viewportWidth: 1280,
    viewportHeight: 720,
    defaultCommandTimeout: 6000,
    video: false,
    retries: { runMode: 2, openMode: 0 },
    env: {
      apiUrl: active.apiUrl,
      target,
    },
    setupNodeEvents(on, _config) {
      // hooks for tasks, plugins
    },
  },
});

Three environments, retry-on-CI, no video by default, sensible viewport. Drop a cypress.env.json next to it for local credential overrides and the project is ready for any of dev/staging/prod with one env-var flip.

⚠️ Common mistakes

  • Committing cypress.env.json. It's the file that holds secrets. Every project should add it to .gitignore on day one alongside a checked-in cypress.env.example.json template. Once a secret has been pushed to a public repo, rotate it immediately — git rm doesn't unship history.
  • Using process.env.SOME_VAR directly in a spec file. That works in the Node-side config and plugin file, but spec code runs in the browser and process.env isn't there. Use Cypress.env(...) in specs and let the config file convert process env vars into Cypress env vars.
  • Bumping defaultCommandTimeout to 30 seconds globally to mask flake. Fail-fast everywhere; surgically extend per-command ({ timeout: 30000 }) on the one element that genuinely needs more time. Global slack just makes failing tests take 26 more seconds to fail.

🎯 Practice task

Wire up environment-aware configuration for your project. 25-30 minutes.

  1. In your scaffolded project, create cypress.env.example.json and cypress.env.json. Add cypress.env.json to .gitignore. Commit only the example.
  2. Refactor cypress.config.ts to support three targets (dev, staging, production) using the process.env.CYPRESS_TARGET switch. Set up baseUrl and apiUrl for each (Sauce Demo as the staging-style URL is fine).
  3. In a spec, read Cypress.env("apiUrl") and Cypress.env("target") and cy.log them at the start of every test. Run CYPRESS_TARGET=staging npm run cy:run. Confirm the log shows the staging URLs.
  4. CLI override drill — run npx cypress run --env apiUrl=https://different.com. Confirm Cypress.env("apiUrl") now returns the CLI value, overriding both the config and cypress.env.json.
  5. Shell-var drillexport CYPRESS_apiUrl=https://shell.example.com && npx cypress run. Confirm the shell value wins over the config but loses to a CLI flag if you also pass --env.
  6. Tune retries — set retries: { runMode: 2, openMode: 0 }. Force a flaky test by toggling a Math.random() > 0.5 assertion. Run cy:run several times and watch the per-test retry log in the output.
  7. Stretch: add a before hook in cypress/support/e2e.ts that asserts Cypress.env("target") is set and matches one of your known values. Tests will now fail fast on a misconfigured environment instead of running against the wrong host.

The last lesson of chapter 5 closes the loop on reuse — data-driven testing, where one test template gets reused across many input rows.

// tip to track lessons you complete and pick up where you left off across devices.