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.comCLI 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_PASSWhere $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 runInside 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 set1280 × 720or1440 × 900to 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 forcy.requestandcy.intercept. Default 5000ms.pageLoadTimeout— how longcy.visitwaits for the load event. Default 60_000ms (60s) — rarely changed.video— record an mp4 of every spec in headless mode. Defaulttrue. Set tofalseif 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. Defaulttrue. 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. Defaulttrueincypress open,falseincypress 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.tsorcypress/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
.envfile (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.gitignoreon day one alongside a checked-incypress.env.example.jsontemplate. Once a secret has been pushed to a public repo, rotate it immediately —git rmdoesn't unship history. - Using
process.env.SOME_VARdirectly in a spec file. That works in the Node-side config and plugin file, but spec code runs in the browser andprocess.envisn't there. UseCypress.env(...)in specs and let the config file convert process env vars into Cypress env vars. - Bumping
defaultCommandTimeoutto 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.
- In your scaffolded project, create
cypress.env.example.jsonandcypress.env.json. Addcypress.env.jsonto.gitignore. Commit only the example. - Refactor
cypress.config.tsto support three targets (dev,staging,production) using theprocess.env.CYPRESS_TARGETswitch. Set upbaseUrlandapiUrlfor each (Sauce Demo as the staging-style URL is fine). - In a spec, read
Cypress.env("apiUrl")andCypress.env("target")andcy.logthem at the start of every test. RunCYPRESS_TARGET=staging npm run cy:run. Confirm the log shows the staging URLs. - CLI override drill — run
npx cypress run --env apiUrl=https://different.com. ConfirmCypress.env("apiUrl")now returns the CLI value, overriding both the config andcypress.env.json. - Shell-var drill —
export 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. - Tune retries — set
retries: { runMode: 2, openMode: 0 }. Force a flaky test by toggling aMath.random() > 0.5assertion. Runcy:runseveral times and watch the per-test retry log in the output. - Stretch: add a
beforehook incypress/support/e2e.tsthat assertsCypress.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.