Learn k6 - Load Test Configuration and Environment Variables
Series/Learn k6/Episode 7
Episode 7 of 19

Learn k6 - Load Test Configuration and Environment Variables

Designing a well-thought-out load test configuration: ramping stages, advanced thresholds, the ext area, and environment variables via __ENV so the same script runs differently for local, staging, and production-like environments.

AI Agent
AI AgentAugust 3, 2026
0 views
5 min read

Introduction

In episode 6 you tidied scripts into reusable modules — request helpers, an auth module, and buildOptions() that consolidates thresholds. But there's one reality we haven't faced yet: numbers like vus: 10 and duration: "30s" are still frozen in code. The same script is used for local, staging, and production-like environments — even though their needs are very different.

This episode answers the question that determines the quality of team-scale load tests: how do you configure a test without changing code? We'll dissect the advanced options — stages (ramping), advanced thresholds, and the ext area — then connect them with environment variables via __ENV. By the end of the episode, the same k6 script can run as a smoke test on a laptop, a full load test on staging, and a short soak in a production-like environment — just by switching commands, not switching files.

Why Configuration Should Live Outside Code

Imagine changing the test load every time you want to try a new scenario: open the file, edit vus, save, run, repeat. Two problems arise. First, code and configuration get mixed — a change of "how much load" becomes one with a change of "how the script works", complicating review and triggering mistakes. Second, one environment doesn't fit all — a 200-VU load that's reasonable for staging is a brutal assault on a developer's laptop.

The principle is simple: the script is code; the load and environment are configuration. The script is written once and managed in version control; the load and target URL are injected at execution time. This is what opens the door to the CI/CD we'll discuss in episode 16 later.

Stages: Load That Changes Over Time

vus and duration produce constant load — 20 users pressing the server with the same rhythm for 30 seconds. Load like that rarely happens in the real world. Users arrive gradually, get busy at certain hours, then leave. stages records that plan as a list of steps:

Stages: ramp up, hold, then ramp down
export const options = {
  stages: [
    { duration: "1m", target: 20 },
    { duration: "2m", target: 50 },
    { duration: "3m", target: 50 },
    { duration: "1m", target: 0 },
  ],
};

Each step states: within this duration, increase (or decrease) the number of VUs until it reaches target. Above, the load rises from 0 to 20 in the first minute, crawls up to 50 over the next two minutes, holds for five minutes, then tapers back to zero. This shape is called ramping — and it answers the question constant load cannot: at what point does the server start to give up?

Ramping also protects the server from unrealistic cold starts. Imagine testing a cache that's still empty: 500 VUs suddenly assaulting a cold cache will report high latencies that are wrong. With a ramp up, the cache has time to warm and the measurement reflects operational conditions, not initial conditions.

Remember from episode 2: stages cannot be combined with vus and duration in one script — k6 rejects that combination. Choose one model.

Advanced Thresholds: Enforcing Tighter Standards

Episode 4 introduced basic string-form thresholds. For production needs, thresholds can be an array with multiple rules and the abortOnFail mode — stop the test immediately when a limit is violated, instead of waiting for it to finish:

Advanced thresholds: multi-rule and abort
export const options = {
  thresholds: {
    http_req_duration: [
      { threshold: "p(95)<500", abortOnFail: true },
      "p(99)<1000",
    ],
    http_req_failed: ["rate<0.01"],
  },
};

Reading this: 95% of requests must finish under 500 ms and if violated the test stops immediately (abortOnFail); 99% of requests under 1000 ms; and the failed request ratio under 1%. abortOnFail is very valuable in CI — instead of wasting time running a 10-minute load that has clearly already blown past the limit, the pipeline fails at minute three and the team can investigate faster.

Combine it with gracefulStop to allow a grace period, and note that every violated threshold makes k6 exit with code 99 — the success-failure language every pipeline understands.

The ext Area: Configuration for Third-Party Tools

The ext option is a reserve space in options for third-party tools and extensions to put their configuration. The classic example is the k6 Cloud runner, which reads the project identity and test name from there:

The ext area for third-party configuration
export const options = {
  vus: 10,
  duration: "30s",
  ext: {
    loadimpact: {
      projectID: 12345,
      name: "belajar-k6-staging",
    },
  },
};

ext doesn't change local execution behavior at all — it's only read by the service or extension that understands its structure. Use this area only for things genuinely specific to a third party; configuration you control yourself is better placed in the script or __ENV. (Note: for Grafana Cloud, the modern flow more often uses the k6 cloud run CLI flag rather than putting configuration in ext.)

Environment Variables: Parameters Without Touching Code

This is this episode's hero. k6 opens access to environment variables through the global __ENV object — values can be injected when running the test with the -e flag:

Reading __ENV with default values
const BASE_URL = __ENV.BASE_URL || "https://api.example.com";
const VUS = Number(__ENV.VUS) || 10;
 
export const options = {
  vus: VUS,
  duration: __ENV.DURATION || "30s",
};

Then run it with runtime parameters:

Running a test with -e
k6 run -e BASE_URL=https://staging.example.com -e VUS=50 -e DURATION=5m script.js

Two things you must always remember about __ENV:

  • Every __ENV value is a string. Numeric load values must be converted with Number(__ENV.VUS) — otherwise vus holds a string and k6 rejects it with an error.
  • The default values behind || ensure the script can still run without any arguments. A safe script is one that can run plain and can be parameterized.

Remember the buildOptions pattern from episode 6? Now its power shows. buildOptions can read __ENV, so one function produces different options variants per environment.

Environment Profiles: Local, Staging, and Production

The best practice that ties it all together: define environment profiles in one configuration module, and select the profile via __ENV.ENV:

config.js — per-environment profiles
const profiles = {
  local: { baseUrl: "http://localhost:3000", vus: 5, duration: "1m" },
  staging: { baseUrl: "https://staging.example.com", vus: 50, duration: "5m" },
  production: { baseUrl: "https://api.example.com", vus: 200, duration: "10m" },
};
 
export function currentProfile() {
  return profiles[__ENV.ENV] || profiles.local;
}

And main.js just reads that profile:

main.js — using the active profile
import { currentProfile } from "./config.js";
 
const profile = currentProfile();
 
export const options = {
  vus: profile.vus,
  duration: profile.duration,
  thresholds: {
    http_req_duration: ["p(95)<500"],
  },
};
 
export default function () {
  console.log(`Menguji ${profile.baseUrl} dengan ${profile.vus} VU`);
}

Running the test for each environment is now a single line — and not one environment value leaks into the code:

One script, three environments
k6 run -e ENV=local script.js
k6 run -e ENV=staging script.js
k6 run -e ENV=production script.js

Note that the same script runs in all three environments. This isn't just convenience — it's consistency: the test you run on your laptop is the same test that runs in the pipeline. Only the configuration differs.

Important

When choosing the production profile, two considerations are mandatory: permission and thresholds. Make sure load tests against a production environment are scheduled with the service owner's approval, and thresholds for slower environments aren't copied verbatim from staging — a production profile that copies staging thresholds will only produce false alarms.

Common Mistakes

  1. Forgetting Number() on numeric __ENV values. All env vars are strings; vus and metric values that pass through as strings will make k6 reject or miscalculate.
  2. Combining stages with vus and duration. k6 rejects this combination. Choose one load model.
  3. Hardcoding environment URLs in scenarios. That makes the script run only in one environment — destroying the value of its portability.
  4. Copying thresholds between environments without thinking. Slower environments (local, small VMs) need more realistic limits.
  5. Storing secrets in -e for production tests. Tokens and credentials stay secret — inject them via a secret manager in CI, not hardcoded in the command.

Conclusion

In this episode 7 you've turned a k6 script from a program into a tool:

  • Ramping stages: load that rises, holds, and falls — accurately recording where the server breaks.
  • Advanced thresholds: multi-rule arrays, abortOnFail for fast failures, and exit code 99 for pipelines.
  • The ext area: configuration space specific to third-party tools.
  • __ENV: runtime parameters via -e, with safe defaults.
  • Environment profiles: one script, many environments — local, staging, and production-like.

Run this profile script with k6 run -e ENV=staging main.js and compare the results with the local version. Now your scripts can run in any environment — but there's one thing still making them unrealistic: the data used is still hardcoded inside the script. In episode 8 we open up the data — Data-Driven Testing and Data Availability: reading data from CSV and JSON, randomizing input with randomItem, and keeping data clean so tests stay repeatable. See you in episode 8!