Resource library

QA How-To

k6 Environment Variables (__ENV) Tutorial

K6 environment variables tutorial for __ENV, -e, runner options, scenario inputs, Docker, CI, and safe secrets, with runnable, verified examples for QA.

19 min read | 3,125 words

TL;DR

Use __ENV to read values passed by k6 run -e, then validate those strings before using them in requests or options. K6-prefixed operating system variables configure supported k6 runner options. Scenario env values are scoped to scenario VUs, and real credentials need protected secret handling.

Key Takeaways

  • Use k6 run -e NAME=value to provide script inputs through __ENV.
  • K6-prefixed system variables configure supported runner options, while -e K6_VUS only supplies a script value.
  • Validate numeric and URL inputs before creating scenarios or making requests.
  • Read scenario env values in the scenario execution function.
  • Keep credentials out of source, command history, debug logs, and summaries.
  • Verify both a passing smoke run and a failing threshold before wiring CI.

K6 Environment Variables Tutorial: use __ENV to keep a load test reusable across local demos, staging, and CI. Pass script inputs with k6 run -e NAME=value. Use K6-prefixed operating system variables only when you intend to configure the k6 runner itself. That distinction determines whether you change a request target or the load schedule.

This guide builds one small HTTP smoke test, validates its inputs, and exercises two scenarios, a token example, Docker, and a CI command. Each step gives you a runnable file and a verification command. The public examples make only a few requests to https://test.k6.io; use an environment you own or are authorized to test before increasing traffic.

What You Will Build

  • An env-demo.js file that reads BASE_URL, TARGET_ENV, USERS, and RUNS from __ENV.
  • A visible experiment that distinguishes -e script values from K6_VUS runner options.
  • A scenario-env.js file with separate paths for two named scenarios.
  • A token-demo.js that demonstrates header construction without printing a credential.
  • A CI smoke command with a meaningful threshold and a failing exit status.

You can expand this lab into a larger suite after understanding the k6 performance engineering guide. Keep the k6 thresholds and checks guide handy because checks alone do not make a CI gate.

Prerequisites

Install Grafana k6 using the current instructions for your operating system. Record the exact installed release with k6 version, then use that same release in CI and any container image. This tutorial does not invent a version number or require npm packages. You need a terminal, a text editor, and permission to create JavaScript files in a new directory. The commands below use a POSIX shell; Step 4 notes the PowerShell form where it matters.

k6 version
mkdir k6-env-lab
cd k6-env-lab

Verification: k6 version prints a version, and the shell enters k6-env-lab. If the binary is missing, use the official installation instructions and retry. Docker is optional until Step 7. Keep this lab separate from a production load suite so its tiny traffic profile stays obvious to reviewers.

Step 1: Start the K6 Environment Variables Tutorial with __ENV

Create env-demo.js with the complete code below. k6 provides __ENV as a global JavaScript object; it is not Node.js process.env and needs no import. The fallback URL makes the first run work without any flags. A shared-iterations scenario schedules one request from one virtual user. The check verifies status 200, and the threshold turns a failing check into a nonzero test result.

import http from 'k6/http';
import { check } from 'k6';

const baseUrl = __ENV.BASE_URL || 'https://test.k6.io';

export const options = {
  scenarios: {
    smoke: {
      executor: 'shared-iterations',
      vus: 1,
      iterations: 1,
      maxDuration: '30s',
    },
  },
  thresholds: {
    checks: ['rate>0.99'],
  },
};

export default function () {
  const response = http.get(baseUrl + '/');
  check(response, {
    'home page returns 200': (r) => r.status === 200,
  });
}

Run it without any input. Reading __ENV during initialization is valid when a value helps construct options or constants. Avoid changing __ENV to communicate between virtual users: each VU has its own JavaScript runtime, so an assignment is not shared state.

k6 run env-demo.js

Verification: the summary should show one iteration, one HTTP request, and a passing checks threshold. A transient outage of the public site may make the check fail; that is a connectivity signal, not proof that __ENV is broken. Preserve this minimal run as a baseline. It helps you isolate a later malformed URL, mistyped variable, or unintended workload change without guessing which new feature caused the failure. Read the execution summary as a contract: one scheduled iteration should produce one request and one check result. Zero requests mean the function may not have reached http.get, perhaps because initialization failed. A request with a failed check means traffic happened but the response missed the assertion. A passing check with an unexpected target points to configuration. These observations separate scheduling, transport, and application behavior before you add more variables.

Step 2: Supply a Target and Label with -e

Replace env-demo.js with this full version. TARGET_ENV labels the destination. A staging label requires an explicit BASE_URL, so a missing CI variable cannot silently fall back to the demo host. The URL guard rejects missing hosts, paths, and non-HTTPS origins, avoiding accidental traffic to a different destination.

import http from 'k6/http';
import { check } from 'k6';

const targetEnv = __ENV.TARGET_ENV || 'demo';
if (!['demo', 'staging'].includes(targetEnv)) {
  throw new Error('TARGET_ENV must be demo or staging');
}
if (targetEnv === 'staging' && !__ENV.BASE_URL) {
  throw new Error('Set BASE_URL for staging');
}
const baseUrl = __ENV.BASE_URL || 'https://test.k6.io';
const origin = baseUrl.replace(/\/$/, '');
if (!/^https:\/\/[A-Za-z0-9.-]+(?::[0-9]{1,5})?$/.test(origin)) {
  throw new Error('BASE_URL must be an HTTPS origin');
}

export const options = {
  scenarios: {
    smoke: {
      executor: 'shared-iterations',
      vus: 1,
      iterations: 1,
      maxDuration: '30s',
    },
  },
  thresholds: {
    checks: ['rate>0.99'],
  },
};

export default function () {
  const response = http.get(origin + '/');
  check(response, {
    'home page returns 200': (r) => r.status === 200,
  });
}

Each -e assignment supplies a string to the script's __ENV object. It does not set a shell variable for later commands, and it does not configure k6's own VU count. Quote values that include spaces or shell metacharacters. The same k6 -e flag syntax works in PowerShell, although PowerShell uses different syntax for setting operating system variables.

k6 run -e TARGET_ENV=demo -e BASE_URL=https://test.k6.io env-demo.js

Verification: expect one request and a passing check again. Then run k6 run -e TARGET_ENV=staging env-demo.js. It should stop with "Set BASE_URL for staging" before sending traffic. That negative check proves the guard is active. When you switch to an authorized staging API, replace the home-page assertion with a stable path and expected response defined by that API. Treat BASE_URL as an origin, such as https://staging.example.test, not as a path or complete endpoint. The code appends /. This restriction avoids accidentally calling a path twice or dropping a query string during normalization. If an API sits under a prefix, introduce a separately validated API_PATH and inspect the resulting request URL in a safe test without printing headers. TARGET_ENV is descriptive; it should never be the only control protecting production from a load command. The API performance testing tutorial shows how to build out those endpoint assertions.

Step 3: Validate Numbers Before Building Options

Every __ENV value is a string. The string "5" does not become a numeric VU count by intent alone, and Number('') unexpectedly yields zero. Add a parser that accepts only positive decimal integers and enforces small limits for this public demonstration. USERS controls VUs, while RUNS controls the total number of shared iterations. Those names are script inputs, not built-in k6 runner settings.

Replace env-demo.js with the complete listing. It retains the target validation from Step 2. The helper is defined before options, so both numeric settings are checked during initialization, before an HTTP request starts.

import http from 'k6/http';
import { check } from 'k6';

function parsePositiveInt(value, fallback, name, maximum) {
  if (value === undefined) return fallback;
  if (!/^[1-9][0-9]*$/.test(value)) {
    throw new Error(name + ' must be a positive integer');
  }
  const number = Number(value);
  if (!Number.isSafeInteger(number) || number > maximum) {
    throw new Error(name + ' exceeds the tutorial limit of ' + maximum);
  }
  return number;
}

const targetEnv = __ENV.TARGET_ENV || 'demo';
if (!['demo', 'staging'].includes(targetEnv)) {
  throw new Error('TARGET_ENV must be demo or staging');
}
if (targetEnv === 'staging' && !__ENV.BASE_URL) {
  throw new Error('Set BASE_URL for staging');
}
const baseUrl = __ENV.BASE_URL || 'https://test.k6.io';
const origin = baseUrl.replace(/\/$/, '');
if (!/^https:\/\/[A-Za-z0-9.-]+(?::[0-9]{1,5})?$/.test(origin)) {
  throw new Error('BASE_URL must be an HTTPS origin');
}
const users = parsePositiveInt(__ENV.USERS, 1, 'USERS', 5);
const runs = parsePositiveInt(__ENV.RUNS, 1, 'RUNS', 20);

export const options = {
  scenarios: {
    smoke: {
      executor: 'shared-iterations',
      vus: users,
      iterations: runs,
      maxDuration: '30s',
    },
  },
  thresholds: {
    checks: ['rate>0.99'],
  },
};

export default function () {
  const response = http.get(origin + '/');
  check(response, {
    'home page returns 200': (r) => r.status === 200,
  });
}
k6 run -e USERS=2 -e RUNS=5 env-demo.js

Verification: expect five iterations and five HTTP requests unless the site or connection fails. Run k6 run -e USERS=0 env-demo.js as a negative test; it must reject zero before executing. In shared-iterations, RUNS is total work across USERS, not work per user. For equal work assigned to each VU, use a per-vu-iterations executor and review k6 scenarios and executors. This matters when comparing two runs: changing VUs without changing total iterations changes concurrency, but does not multiply the request count here. The parser also rejects 05, 2.5, whitespace, and very large integers instead of silently coercing them. Strict input handling makes a pipeline configuration error visible. For a real workload, choose upper bounds from capacity policy, not from this tutorial. If staging needs a larger run, change the ceiling in reviewed source and explain why. When a run completes fewer than RUNS iterations, inspect timeout and interruption messages before drawing conclusions from latency metrics.

Step 4: Distinguish Script Inputs from K6 Runner Options

K6_ has a special role in the operating system environment. A variable such as K6_VUS=3 can configure k6's VU option. In contrast, -e K6_VUS=3 only supplies a string named K6_VUS to __ENV; a script must read and use that string for it to have an effect. Keep this experiment separate from env-demo.js, which defines its own scenario. Create option-demo.js to observe simple top-level option precedence without mixing two workload models.

import http from 'k6/http';
import { check } from 'k6';

export const options = {
  vus: 1,
  iterations: 2,
  thresholds: {
    checks: ['rate>0.99'],
  },
};

export default function () {
  const response = http.get('https://test.k6.io/');
  check(response, {
    'demo page returns 200': (r) => r.status === 200,
  });
}

Run these three commands separately. The first gives the script a variable it never reads, so the declared single VU remains. The second uses an operating system variable to override vus. The third adds an explicit CLI --vus flag, which has higher precedence than the system variable. Grafana documents the general precedence as defaults, configuration file, script options, environment options, and CLI flags.

k6 run -e K6_VUS=3 option-demo.js
K6_VUS=3 k6 run option-demo.js
K6_VUS=3 k6 run --vus 2 option-demo.js

Verification: inspect the execution line near the start. It should announce one, three, then two VUs; each command still plans two iterations. In PowerShell, set $env:K6_VUS='3', execute k6, then remove it with Remove-Item Env:K6_VUS. If a CI run announces unexpected settings, inspect inherited K6_DURATION and K6_ITERATIONS too. An environment supplied by a job runner can override script settings without anyone editing the test file.

Input form Destination Typical purpose
k6 run -e BASE_URL=https://test.k6.io __ENV.BASE_URL Script target
BASE_URL=https://test.k6.io k6 run ... __ENV.BASE_URL in local run Short-lived local input
K6_VUS=3 k6 run ... k6 option system Runner VU override
k6 run --vus 3 ... k6 CLI option Explicit highest-priority override
k6 run -e K6_VUS=3 ... __ENV.K6_VUS only Script value, if code reads it

The route into k6 is as important as the variable's name. When reviewing a load command, identify which component consumes every input before trusting the resulting schedule. This matters when debugging a command copied from a CI file. A YAML env mapping creates operating system variables, while arguments in a run line can supply -e values. The two can coexist: a job may set K6_VUS for a simple script and pass BASE_URL through -e. Review the announced schedule and the target separately. A correct URL does not prove the VU count was right, and a correct VU count does not prove the intended host received the traffic.

Step 5: Use Scenario-Specific Environment Values

Two journeys can share a function but request different paths. Create scenario-env.js below. The home and contacts scenarios each set PATH inside their env object. Their VUs execute the exported visit function, which reads __ENV.PATH. Tags identify the flow in metrics; the env value determines behavior. Those are related but separate mechanisms.

import http from 'k6/http';
import { check } from 'k6';

const baseUrl = __ENV.BASE_URL || 'https://test.k6.io';

export const options = {
  scenarios: {
    home: {
      executor: 'shared-iterations',
      vus: 1,
      iterations: 1,
      maxDuration: '30s',
      env: { PATH: '/' },
      exec: 'visit',
      tags: { flow: 'home' },
    },
    contacts: {
      executor: 'shared-iterations',
      vus: 1,
      iterations: 1,
      maxDuration: '30s',
      env: { PATH: '/contacts.php' },
      exec: 'visit',
      tags: { flow: 'contacts' },
    },
  },
  thresholds: {
    checks: ['rate>0.99'],
  },
};

export function visit() {
  const response = http.get(baseUrl + __ENV.PATH);
  check(response, {
    'scenario page returns 200': (r) => r.status === 200,
  });
}

The top-level BASE_URL is available during initialization. A scenario-local PATH belongs to the VUs executing that scenario, so read it inside visit. Do not expect scenario env values in setup, teardown, or top-level initialization. Pass values needed across the whole script using -e or, for local execution, an explicitly set system variable. This distinction prevents a subtle bug where a URL is built before the scenario-specific path exists. Scenario env is useful for nonsecret routes, locale choices, and test-data partitions. It is unsuitable for credentials because values in script options become part of reviewed source. The scenario tag is attached to emitted metrics; you can later threshold home latency separately from contacts latency instead of averaging both flows together. That makes a one-page regression easier to diagnose. Keep path values stable where possible, since uncontrolled dynamic paths make results harder to group across runs.

k6 run scenario-env.js

Verification: the summary should report two iterations and two HTTP requests, and the run should name both scenarios. If the contacts check fails, confirm the demo site's path directly before weakening the assertion. For a complex workload with mixed traffic, add thresholds filtered by scenario or flow tag, using the patterns in k6 scenarios and executors.

Step 6: Handle Tokens Without Logging Them

Ordinary __ENV values are not automatically redacted from logs. An -e token can also appear in shell history and process arguments. For real credentials, prefer protected CI secrets or supported k6 secret sources. Never print the whole __ENV object, and avoid full HTTP debugging for a request with Authorization headers. This token-demo.js is runnable with a fake token; it shows where a header belongs without claiming the public site validates authentication.

import http from 'k6/http';
import { check } from 'k6';

const baseUrl = __ENV.BASE_URL || 'https://test.k6.io';
const token = __ENV.API_TOKEN;

export const options = {
  scenarios: {
    smoke: {
      executor: 'shared-iterations',
      vus: 1,
      iterations: 1,
      maxDuration: '30s',
    },
  },
  thresholds: {
    checks: ['rate>0.99'],
  },
};

export default function () {
  const headers = token
    ? { Authorization: 'Bearer ' + token }
    : {};
  const response = http.get(baseUrl + '/', { headers });
  check(response, {
    'page returns 200': (r) => r.status === 200,
  });
}
k6 run -e API_TOKEN=demo-only token-demo.js

Verification: the summary should pass without displaying demo-only from the script's console output. That only proves the script does not print it; the command argument is still visible to the shell and may be recorded elsewhere. When adapting this to your API, replace the URL and assertion with an authorized protected endpoint, and use a disposable credential for the first check. Grafana's secret source documentation describes secrets.get() and log redaction for supported sources. Do not label a plain environment variable "safe" merely because no console.log call appears in this example. A token can leak through HTTP debug output, an error payload, a shell transcript, or a process listing even when the script stays quiet. For CI, define who may read the secret, scope it to the test environment, and rotate it after a rehearsal with debug logging. Use a token with only the permissions needed for the endpoint. If the API returns 401, verify header format and credential scope without pasting the value into logs. A fast 401 is failed authentication, not a successful performance result.

Step 7: Reproduce the Run in Docker

A Docker command has two places for environment options. docker run -e sets a container process variable; k6 run -e sets a value for the k6 script. To make the script input explicit, put the k6 flags after the image name and run subcommand. Use the exact image tag that matches the k6 version recorded in Prerequisites. Replace the placeholder below with a real published tag for that version; the placeholder itself is not a tag to execute.

docker run --rm -v "$PWD:/scripts" -w /scripts \
  grafana/k6:<your-installed-k6-version> \
  run -e TARGET_ENV=demo -e BASE_URL=https://test.k6.io env-demo.js

The volume mount exposes env-demo.js in /scripts inside the container. The working-directory flag makes the relative filename resolve there. Because USERS and RUNS are absent, env-demo.js uses their one-request defaults. For a real staging run, pass only a host you are permitted to test. Do not solve a missing volume mount by changing the script's URL or by copying a local token into the image.

Verification: expect one HTTP request and a passing threshold. An image-not-found error means the version tag or registry needs checking, not that __ENV failed. A file-not-found error points to the mount or working directory. On Windows, adapt the volume path to Docker Desktop and your shell. Keep the JavaScript identical across local and container runs so a difference in output can be traced to execution context or input values.

Step 8: Verify the K6 Environment Variables Tutorial in CI

A useful CI smoke needs a known k6 release, an authorized destination, and a threshold that can fail the job. The final env-demo.js from Step 3 provides bounded work and a checks threshold. Set CI_STAGING_URL as a nonsecret CI variable containing the HTTPS origin of a staging service your team owns. This POSIX shell fragment rejects an absent value, prints the binary version, and runs five total iterations.

test -n "$CI_STAGING_URL" || { echo "Set CI_STAGING_URL"; exit 1; }
k6 version
k6 run \
  -e TARGET_ENV=staging \
  -e BASE_URL="$CI_STAGING_URL" \
  -e USERS=2 \
  -e RUNS=5 \
  env-demo.js

Do not append "|| true" to the k6 command. If the checks threshold fails, its nonzero exit status should fail the CI step. Your staging service must return 200 at / for this exact script; otherwise change the request and assertion to the service's stable authorized health route. Record the k6 version and target name with results, but do not dump the full environment or secret values into artifacts.

Verification: in a disposable local shell, set CI_STAGING_URL=https://test.k6.io and run the fragment exactly. Expect five requests and a passing checks threshold. Then set a deliberately invalid HTTPS test host and confirm that the job fails. This tests the gate as well as the successful path. Keep this smoke job small; a separate scheduled or pre-release load profile can follow the k6 load testing tutorial.

Troubleshooting

Problem: __ENV.BASE_URL is undefined after using -e. Fix: put the flag after k6 run and before the script filename, and match the code's capitalization. Repeat the Step 2 command verbatim. For a local shell variable, confirm it is exported or assigned on the same command line.

Problem: -e K6_VUS=10 did not start ten VUs. Fix: use K6_VUS=10 k6 run for a supported runner option, or an appropriate CLI flag in a simple script. With explicit scenarios, change scenario settings rather than stacking top-level shortcuts. Step 4 isolates the behavior.

Problem: a numeric input becomes zero or invalid. Fix: validate the string before converting it to an option. The Step 3 helper rejects empty strings, negative values, decimals, and values over the tutorial limit. Confirm the announced schedule after correcting the command.

Problem: a scenario value is absent in setup. Fix: use a top-level -e value for data needed by setup, and read scenario-local values inside the scenario's exec function. The Step 5 PATH variable is deliberately consumed only by visit.

Problem: local execution sees a system variable but a cloud or archive command does not. Fix: pass script values explicitly with -e. k6 excludes system variables by default for some nonlocal commands to reduce accidental disclosure; review --include-system-env-vars before opting in.

Problem: a token appears in a log or CI artifact. Fix: revoke that credential, remove debug output containing headers or __ENV, and rerun with a disposable secret. Move production values to a supported secret source or protected CI secret store.

Interview Questions and Answers

The interviewQnA field below has six focused model answers. A strong explanation distinguishes script inputs, runner options, precedence, string conversion, scenario scope, and secret handling. In an interview, use the two Step 4 commands to show the distinction concretely; saying "k6 reads environment variables" alone hides the behavior that causes most configuration mistakes.

Common Mistakes

  • Saving a production token in a script, README, shell screenshot, or pasted command.
  • Accepting an empty BASE_URL during a staging run and silently targeting a public demo.
  • Assuming failed checks alone fail CI without a checks threshold.
  • Reading a scenario's PATH during module initialization instead of inside its exec function.
  • Mixing K6_VUS overrides with explicit scenario scheduling without inspecting the result.
  • Logging the entire __ENV object to debug one missing input.
  • Increasing traffic against a shared or third-party host without permission and a smoke baseline.

These mistakes lead to different symptoms: a wrong destination, an unexpected load shape, a false green job, or credential exposure. Run the verification command immediately after each edit so the failure has a narrow cause. For an error-rate gate beyond the single checks threshold, revisit k6 thresholds and checks.

Where To Go Next

Replace the demo origin with a controlled test API, then add endpoint-specific checks and tagged thresholds. The performance testing with k6 scripts guide covers broader script structure. Use k6 scenarios and executors to choose a workload model before raising traffic. If you later distribute runs, read k6 distributed load testing with the Kubernetes operator before carrying values into pods.

Conclusion

A sound k6 environment variables tutorial ends with a script whose destination and small workload can change from the command line while its checks and safety bounds stay visible in code. Use __ENV for script inputs, validate strings before they influence requests or options, and reserve K6-prefixed system variables for runner settings.

Run the CI smoke against an authorized staging endpoint, confirm its failure path, and only then design larger profiles. That sequence produces a test the team can review and trust.

Interview Questions and Answers

What does __ENV contain in k6?

It exposes environment values available to the k6 JavaScript runtime, including values passed with -e. I use it for base URLs, profile labels, or test data selectors, then validate each value before it controls traffic.

Explain the difference between -e K6_VUS=5 and K6_VUS=5 k6 run.

The first form passes a string named K6_VUS to the script through __ENV. The second form sets a system environment variable that k6 can interpret as its VU option. I inspect the announced execution schedule to verify which path was applied.

How does k6 resolve conflicting option values?

The order is built-in defaults, configuration file, script options, environment options, then CLI flags. I prefer an explicit CLI override for one-off experiments and keep stable workload definitions in version-controlled script options.

How do you make numeric __ENV values safe?

I require a decimal positive-integer string, convert it with Number, check Number.isSafeInteger, and enforce a workload ceiling. That avoids empty strings turning into zero and prevents a typo from scheduling excessive traffic.

When would you use scenario-specific env?

When two scenarios share an exec function but need different nonsecret paths or labels. I put the values under each scenario's env object and read them inside the function executed by that scenario's VUs.

What is your policy for tokens in k6 tests?

I use protected CI secrets or supported k6 secret sources and never print credentials or enable full HTTP debug for secret-bearing requests. An -e argument is fine for a fake demonstration token, but process arguments and shell history make it unsuitable for a real long-lived credential.

Frequently Asked Questions

How do I pass an environment variable to a k6 script?

Run k6 run -e BASE_URL=https://test.k6.io env-demo.js, then read __ENV.BASE_URL in the JavaScript file. The -e option supplies a script value and does not configure a runner option by itself.

Is __ENV the same as Node.js process.env?

No. k6 provides __ENV as a global JavaScript object in its own runtime. A k6 script does not need to import Node.js process to read values supplied by -e.

Why does -e K6_VUS=10 not start ten virtual users?

The -e flag adds K6_VUS to __ENV. To configure a supported k6 option through the operating system, set K6_VUS=10 before k6 run, or use an appropriate CLI flag or scenario configuration.

Are k6 environment variable values strings?

Values supplied through __ENV are strings. Convert and validate numeric inputs before placing them in options, and reject empty, negative, or out-of-range values explicitly.

Can scenario env variables be read from setup?

Scenario env values belong to the scenario's VUs, so read them in the scenario's exec function. Pass values needed by setup at the global script level, such as with -e.

How do I keep an API token safe in a k6 test?

Avoid source commits, console printing, full HTTP debug output, and real tokens on visible command lines. Use protected CI secrets or a supported k6 secret source, and test with disposable credentials.

Why does a variable work in k6 run but not k6 cloud run?

Local k6 run includes system environment variables by default, while some other commands exclude them to reduce accidental disclosure. Supply script inputs explicitly with -e or review --include-system-env-vars and its security implications.

Related Guides