---
title: "Managing Environments and Variables in k6 for Safer Tests"
description: "Use __ENV, CLI flags, and modular options to separate dev/stage/prod configs without leaking secrets—aligned with k6's runtime model."
publishDate: "2026-05-29"
draft: false
keyword: "test environment variables k6"
intent: "MOFU"
cta: "Use Performate to manage environments and variable switches without duplicating scripts."
tags: ["k6", "environments", "variables", "testing"]
---

Mixing staging URLs with production tokens has caused real outages—and duplicated k6 scripts that silently diverge within a sprint. k6 treats configuration as **data supplied at runtime**: environment variables via `__ENV`, CLI `-e` flags, and `options` blocks parsed at startup ([environment variables](https://k6.io/docs/using-k6/environment-variables/), [options](https://k6.io/docs/using-k6/options/)).

Managing environments in k6 is not about `.env` files checked into git. It is about **one script**, **explicit mandatory variables**, and **profiles** that CI, Docker, and local shells inject consistently. In this guide you will learn patterns for endpoints and secrets, env-driven scenario tuning, and anti-patterns that leak credentials or hide wrong-target runs.

Pair this with [Postman to k6](/en/blog/postman-to-k6-step-by-step) when collections already encode named environments, and [load testing in CI/CD](/en/blog/load-testing-in-ci-cd) for injecting secrets in pipelines.

## Why environment drift breaks load tests

The same script against the wrong base URL does not fail loudly—it produces plausible latency numbers for the wrong system.

- **Hardcoded prod URLs** "temporarily" become permanent; staging never gets exercised.
- **Forked scripts per env** (`checkout-staging.js`, `checkout-prod.js`) drift on thresholds and payloads.
- **Missing validation** lets runs proceed halfway through a soak before `undefined` tokens surface.
- **Container vs laptop drift:** `TZ`, DNS, and OAuth clock skew break auth refresh scenarios identically in CI and nowhere locally.
- **Logged secrets** in debug output end up in CI artifacts forever.

Think of configuration like airport gates: the flight plan (script) stays fixed; boarding passes (env vars) decide which runway you actually hit.

### When tests "pass" against the wrong target

Green thresholds against an idle dev sandbox prove nothing about staging readiness. Mandatory env validation in `setup()` turns misconfiguration into an immediate, readable failure before virtual users spawn.

## Practical k6 implementation: profiles without script forks

Keep scenario definitions stable; vary duration, rates, and thresholds through env vars parsed into `options`. Document required keys at the top of every script.

**Example script (illustrative—not a production-ready test).** Uses fictional URLs, tokens, and tuning values. Adapt variable names and profiles to your org.

**What this example demonstrates:**

- **Mandatory env guard:** `setup()` throws when `BASE_URL` or `API_TOKEN` is missing.
- **Profile switch:** `ENV_PROFILE=dev|stage|load` selects duration and rate without separate files.
- **Shared route logic:** one `hitCheckout()` function; env only changes targets and load shape.
- **Safe defaults:** staging URL as fallback only for local dev—not for CI (CI must pass explicit `-e`).

```javascript
import http from 'k6/http';
import { check, sleep } from 'k6';

// REQUIRED: BASE_URL, API_TOKEN — pass via -e or CI secrets
const BASE = __ENV.BASE_URL || 'https://staging.example.com';
const TOKEN = __ENV.API_TOKEN;
const PROFILE = __ENV.ENV_PROFILE || 'stage'; // dev | stage | load

const profiles = {
  dev: { duration: '30s', rate: 2, maxVUs: 5 },
  stage: { duration: '2m', rate: 10, maxVUs: 40 },
  load: { duration: '10m', rate: 40, maxVUs: 200 },
};

const cfg = profiles[PROFILE] || profiles.stage;

export function setup() {
  if (!__ENV.BASE_URL && PROFILE !== 'dev') {
    throw new Error('Set BASE_URL explicitly for non-dev profiles');
  }
  if (!TOKEN) {
    throw new Error('Set API_TOKEN via -e or CI secret mapping');
  }
  return { base: BASE };
}

export const options = {
  scenarios: {
    checkout: {
      executor: 'constant-arrival-rate',
      rate: cfg.rate,
      timeUnit: '1s',
      duration: cfg.duration,
      preAllocatedVUs: Math.min(cfg.maxVUs, 20),
      maxVUs: cfg.maxVUs,
      exec: 'hitCheckout',
      tags: { env: PROFILE, route: 'checkout' },
    },
  },
  thresholds: {
    http_req_failed: ['rate<0.01'],
    'http_req_duration{route:checkout}': ['p(95)<900'],
  },
};

export function hitCheckout() {
  const res = http.post(
    `${BASE}/checkout`,
    JSON.stringify({ sku: 'SKU-100', qty: 1 }),
    {
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${TOKEN}`,
      },
      tags: { route: 'checkout', env: PROFILE },
    },
  );
  check(res, { 'checkout 2xx': (r) => r.status >= 200 && r.status < 300 });
  sleep(0.3);
}
```

**Patterns that work**

- **`__ENV` for secrets and endpoints**—read in `setup()` or module scope with loud failures ([environment variables](https://k6.io/docs/using-k6/environment-variables/)).
- **CLI injection:** `k6 run -e BASE_URL=... -e API_TOKEN="$TOKEN" script.js`—never commit secrets.
- **Explicit stages per environment** via `Number(__ENV.VUS)` or profile maps—avoid duplicated `options` blocks.
- **Docker and cloud runners:** map secrets to env vars identically; verify `TZ` and DNS match laptops.
- **Shared defaults + overrides:** document `BASE_URL`, `TENANT_ID`, `ENV_PROFILE` in a header comment block.

**Anti-patterns to avoid**

- Hardcoding prod URLs—even "for one demo."
- Duplicating entire scripts per env instead of parameters.
- Logging tokens during debugging—scrub CI artifacts and local consoles.
- Silent fallbacks to staging in CI jobs meant to hit preview environments.

**Pro tip (example commands):** same script, three targets—no copy/paste forks.

```bash
# Local dev — short, low rate
k6 run checkout.js -e ENV_PROFILE=dev -e API_TOKEN="$DEV_TOKEN"

# CI staging gate
k6 run checkout.js -e ENV_PROFILE=stage \
  -e BASE_URL="$STAGING_URL" -e API_TOKEN="$STAGING_TOKEN"

# Scheduled load profile
k6 run checkout.js -e ENV_PROFILE=load \
  -e BASE_URL="$STAGING_URL" -e API_TOKEN="$STAGING_TOKEN"
```

**What these commands demonstrate:** profile selection is a flag change; BASE_URL must be explicit for CI and scheduled runs.

## Decision framework: when to use which pattern

| Situation | Recommended action |
|:---|:---|
| Local iteration | `ENV_PROFILE=dev`; optional staging default URL |
| CI smoke gate | Mandatory `BASE_URL` + secrets; short profile only |
| Scheduled soak | `ENV_PROFILE=load`; explicit staging or isolated env |
| Multi-tenant APIs | `TENANT_ID` env; tag requests `{tenant:...}` |
| Postman-sourced tests | Map collection environments to `-e` flags at export time |
| OAuth clock sensitivity | Align `TZ`; validate token refresh in `setup()` |

**Use profile maps if** duration and rate change per environment but routes stay identical.

**Use mandatory `setup()` checks if** wrong-target runs are unacceptable—typical for any shared script repo.

**Use collection environment export if** QA already maintains Postman env JSON—see [Postman to k6](/en/blog/postman-to-k6-step-by-step).

## Observability and safety checklist

Before sharing scripts across teams:

- [ ] List required env vars in script header and README fragment in PR template.
- [ ] Fail fast in `setup()` when secrets or BASE_URL missing for non-dev profiles.
- [ ] Tag requests with `env:` profile for filtered reporting.
- [ ] Scrub logs; disable verbose HTTP debug in CI.
- [ ] Archive env profile name (not secret values) alongside summary JSON per run.

## How Performate simplifies environment switching

Centralizing imports and toggles beats maintaining three copies of the same logic. Below is a **concrete workflow example** for the checkout API this article uses.

**Example: staging vs load profile without script forks**

1. **Import one Postman collection** with environment variables for `baseUrl` and `apiToken`. *Problem solved:* QA environments become performance environments without re-entry.
2. **Define environment profiles** in Performate—Dev, Staging, Load—with duration and arrival rate presets matching `ENV_PROFILE` above. *Problem solved:* engineers toggle targets in UI instead of editing executor blocks.
3. **Map collection variables** to scenario fields once; re-use across smoke and soak scenarios. *Problem solved:* payload and auth headers stay synchronized.
4. **Run Staging profile** for pre-release gates; switch to Load profile for scheduled regression—same requests, different shape. *Problem solved:* no `checkout-staging.js` / `checkout-load.js` drift.
5. **Export k6 script** with `__ENV` placeholders for CI secret injection. *Problem solved:* desktop tuning and pipeline execution share one artifact.
6. **Share reports** filtered by environment tag so incidents compare the same profile baseline. *Problem solved:* on-call reads `env:stage`, not mystery laptop runs.

That workflow maps directly to the `cta` in this post: manage environments and variable switches without duplicating scripts.

## Closing takeaway

Safer k6 tests treat **environment as runtime input**: one script, explicit mandatory variables, profiles for shape—not separate repos that diverge.

Add a `setup()` guard for `BASE_URL` and `API_TOKEN` this week, delete the "temporary" prod URL in your oldest script, and document which profile CI runs by default.

[Try Performate free](https://performate.app) | [Book a demo](/demo) | [k6 options reference](https://k6.io/docs/using-k6/options/)
