A K6 script that only runs on a developer's laptop is a manual test, not a quality gate. Integrating K6 into GitHub Actions transforms it into automated enforcement: every merge to main runs a load test, nightly runs catch gradual regression, and failing thresholds block deployments before they reach production.
The CI pipeline strategy
Basic workflow with the grafana/k6-action
The official K6 GitHub Action handles installation and execution with minimal configuration:
name: Performance Tests
on:
push:
branches: [main]
schedule:
- cron: '0 2 * * *' # nightly at 2am UTC
workflow_dispatch: # manual trigger
jobs:
k6-load-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run K6 load test
uses: grafana/k6-action@v0.3.1
with:
filename: tests/load-test.js
env:
BASE_URL: ${{ secrets.STAGING_URL }}
API_TOKEN: ${{ secrets.API_TOKEN }}
- name: Upload test artifacts
if: always() # upload even when the test fails
uses: actions/upload-artifact@v4
with:
name: k6-results
path: results/
retention-days: 30The if: always() on the artifact upload step is critical: when a K6 threshold fails, the Action exits with code 108, which GitHub Actions treats as a job failure and skips subsequent steps by default. You want the HTML report even when the test fails — that is when it is most useful.
Accessing secrets as environment variables
K6 reads environment variables through __ENV:
// In your K6 script
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
const API_TOKEN = __ENV.API_TOKEN;
export function setup() {
const res = http.post(`${BASE_URL}/auth/login`,
JSON.stringify({ token: API_TOKEN }),
{ headers: { 'Content-Type': 'application/json' } }
);
return { authToken: res.json('token') };
}Never hard-code staging URLs or tokens in the script. Store them as GitHub repository secrets and pass via env: in the workflow. This keeps credentials out of version control and lets the same script run against different environments by changing the secret.
Running on K6 Cloud from CI
For tests that need geographic distribution or exceed what a single CI runner can generate, run on Grafana Cloud K6:
- name: Run on Grafana Cloud K6
uses: grafana/k6-action@v0.3.1
with:
filename: tests/load-test.js
cloud: true
env:
K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN }}
K6_CLOUD_PROJECT_ID: ${{ secrets.K6_CLOUD_PROJECT_ID }}The cloud run exits with the same code as a local run — 0 for pass, 108 for threshold failure — so CI pipeline behaviour is identical.
Custom Docker step
If you need more control over the K6 version or execution environment:
- name: Run K6 with Docker
run: |
docker run --rm \
-v $(pwd):/work \
-w /work \
-e BASE_URL=${{ secrets.STAGING_URL }} \
-e API_TOKEN=${{ secrets.API_TOKEN }} \
grafana/k6:latest run \
--out json=results/results.json \
tests/load-test.jsThe -v $(pwd):/work mount makes the repository available inside the container at /work. Use a pinned K6 version (grafana/k6:0.54.0) rather than latest for reproducible builds.
Separating smoke tests from load tests
Two jobs: a fast smoke check that runs on every PR and a full load test that runs on merge:
name: Performance Tests
on:
pull_request:
push:
branches: [main]
jobs:
smoke-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Smoke performance check
uses: grafana/k6-action@v0.3.1
with:
filename: tests/smoke.js # vus: 1, duration: '60s'
env:
BASE_URL: ${{ secrets.STAGING_URL }}
load-test:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' # only on main branch
needs: smoke-test # wait for smoke test to pass
steps:
- uses: actions/checkout@v4
- name: Full load test
uses: grafana/k6-action@v0.3.1
with:
filename: tests/load-test.js
env:
BASE_URL: ${{ secrets.STAGING_URL }}
API_TOKEN: ${{ secrets.API_TOKEN }}
- name: Upload HTML report
if: always()
uses: actions/upload-artifact@v4
with:
name: load-test-report
path: report.htmlThe needs: smoke-test dependency prevents the full load test from running if the smoke check fails. The if: github.ref == 'refs/heads/main' condition skips the load test on PR branches where it would be redundant.
⚠️ Common mistakes
- Running full load tests on every PR. A 30-minute load test with 100 VUs on every PR consumes CI minutes, blocks the PR review queue, and adds no value over a well-designed 60-second smoke test. Reserve full load tests for the main branch or scheduled runs.
- Not using
if: always()for report upload. When a threshold fails, the step exits with code 108 and GitHub Actions skips all subsequent steps. Always mark report upload asif: always(). - Hard-coding staging URLs in the script. Use
__ENV.BASE_URLand pass the value through the workflow'senv:block. This lets the same script run locally (with a local.envfile ork6 run --env BASE_URL=http://localhost:3000) and in CI (with secrets) without changing the script. - Not pinning the K6 version.
grafana/k6-action@v0.3.1uses whatever K6 version the Action bundles. Pin to a specific K6 version with thek6-versioninput to prevent unexpected behaviour when the Action updates.
🎯 Practice task
Set up a GitHub Actions workflow that runs K6 tests on push. 40 minutes.
- Create a minimal K6 smoke test at
tests/smoke.js:vus: 1, duration: '30s', targetinghttps://test.k6.io. Add two thresholds:http_req_duration: ['p(95)<1000']andhttp_req_failed: ['rate<0.01']. - Create
.github/workflows/performance.ymlwith the basic workflow structure from this lesson. Use the smoke test for thepull_requesttrigger. - Add
handleSummaryto the smoke test that writesresults/report.html. Add the artifact upload step withif: always(). - Push to a branch and open a PR. Verify the workflow runs and the HTML report appears as a build artifact.
- Tighten the threshold to
p(95)<1ms(impossible to meet). Push again. Verify the CI job fails with exit code 108 and the artifact is still uploaded. - Add a second job
load-testthat only runs onpushtomainand depends onsmoke-testpassing. Use a separatetests/load-test.jswithvus: 10, duration: '2m'.