Running K6 in GitHub Actions

9 min read

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: 30

The 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.js

The -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.html

The 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 as if: always().
  • Hard-coding staging URLs in the script. Use __ENV.BASE_URL and pass the value through the workflow's env: block. This lets the same script run locally (with a local .env file or k6 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.1 uses whatever K6 version the Action bundles. Pin to a specific K6 version with the k6-version input to prevent unexpected behaviour when the Action updates.

🎯 Practice task

Set up a GitHub Actions workflow that runs K6 tests on push. 40 minutes.

  1. Create a minimal K6 smoke test at tests/smoke.js: vus: 1, duration: '30s', targeting https://test.k6.io. Add two thresholds: http_req_duration: ['p(95)<1000'] and http_req_failed: ['rate<0.01'].
  2. Create .github/workflows/performance.yml with the basic workflow structure from this lesson. Use the smoke test for the pull_request trigger.
  3. Add handleSummary to the smoke test that writes results/report.html. Add the artifact upload step with if: always().
  4. Push to a branch and open a PR. Verify the workflow runs and the HTML report appears as a build artifact.
  5. 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.
  6. Add a second job load-test that only runs on push to main and depends on smoke-test passing. Use a separate tests/load-test.js with vus: 10, duration: '2m'.

// tip to track lessons you complete and pick up where you left off across devices.