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.

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.
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.
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:
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.
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:
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 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:
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.)
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:
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:
k6 run -e BASE_URL=https://staging.example.com -e VUS=50 -e DURATION=5m script.jsTwo things you must always remember about __ENV:
__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.|| 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.
The best practice that ties it all together: define environment profiles in one configuration module, and select the profile via __ENV.ENV:
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:
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:
k6 run -e ENV=local script.js
k6 run -e ENV=staging script.js
k6 run -e ENV=production script.jsNote 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.
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.stages with vus and duration. k6 rejects this combination. Choose one load model.-e for production tests. Tokens and credentials stay secret — inject them via a secret manager in CI, not hardcoded in the command.In this episode 7 you've turned a k6 script from a program into a tool:
abortOnFail for fast failures, and exit code 99 for pipelines.ext area: configuration space specific to third-party tools.__ENV: runtime parameters via -e, with safe defaults.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!