Load testing in GitHub Actions: a practical guide
6 min read
Load testing in GitHub Actions takes one workflow file: install the tool, run a short test against a staging URL and let the tool’s exit code fail the job when a threshold is missed. Upload the results as an artifact so every run leaves a record. Keep the test small, because a standard GitHub-hosted runner is one virtual machine with a few CPU cores.
Tool and GitHub facts below come from each vendor’s own documentation, checked on 9 October 2026, and the workflows follow that documented usage.
| Tool | Installed by | What fails the job | Results to keep |
|---|---|---|---|
| k6 | Grafana’s setup-k6-action | A failed threshold | JSON output |
| Artillery | artilleryio/action-cli | A failed ensure check | report.json |
| Locust | pip install locust | Any failed request, or your own rule | CSV and HTML files |
| JMeter | The release archive | Nothing built in: add a check step | .jtl file and HTML report |
What does a load test workflow need?
A trigger, a runner, a step that installs the tool, a step that runs the test with thresholds and a step that uploads the results. Everything else is optional.
Keep the test script in the repository, for example under tests/load/. Read the staging URL from a repository variable through the vars context, and set timeout-minutes on the job: the default is 360 minutes (checked on 9 October 2026).
Which events should start a load test?
Run a short test on pull requests and a longer one on a schedule or by hand.
Use pull_request to catch a regression before the merge, workflow_dispatch for manual runs and schedule for cron runs (UTC unless you set a timezone). GitHub’s event reference adds limits (checked on 9 October 2026): schedules run only on the default branch, at most every five minutes, and can be delayed at the start of the hour. Pull requests from forks get no secrets except GITHUB_TOKEN.
How do you run k6 in GitHub Actions?
Use Grafana’s two official actions, one to install k6 and one to run the script. The step fails whenever k6 exits with a non-zero code, which a failed threshold causes.
Both setup-k6-action and run-k6-action are on v1 (checked on 9 October 2026):
name: load-test
on:
pull_request:
workflow_dispatch:
jobs:
k6:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
BASE_URL: ${{ vars.STAGING_URL }}
steps:
- uses: actions/checkout@v7
- uses: grafana/setup-k6-action@v1
- uses: grafana/run-k6-action@v1
with:
path: tests/load/smoke.js
flags: --out json=results.json
- uses: actions/upload-artifact@v7
if: always()
with:
name: k6-results
path: results.json
retention-days: 14
The thresholds live in the script, which reads BASE_URL from the environment:
import http from "k6/http";
export const options = {
vus: 10,
duration: "1m",
thresholds: {
http_req_failed: ["rate<0.01"],
http_req_duration: ["p(95)<500"],
},
};
export default function () {
http.get(`${__ENV.BASE_URL}/health`);
}
k6’s documentation notes that checks do not affect the exit status, so the pass/fail decision has to come from thresholds.
How do you run Artillery, Locust or JMeter instead?
Swap the install and run steps, keep the rest of the workflow, and use each tool’s own switch for failing on a missed target.
Artillery has an official GitHub Action whose README says @v2 tracks the latest 2.x release (checked on 9 October 2026). The step is uses: artilleryio/action-cli@v2 with command: run --target ${{ vars.STAGING_URL }} tests/load/api.yml --output report.json, and the ensure plugin exits non-zero when a target is missed. The script’s config section:
config:
target: "https://staging.example.com"
phases:
- duration: 60
arrivalRate: 5
plugins:
ensure:
thresholds:
- "http.response_time.p95": 500
Locust exits with code 1 when any request failed, and for latency rules you set environment.process_exit_code in a quitting event listener, as that page shows (checked on 9 October 2026). After actions/setup-python@v7 with python-version: "3.13":
- run: pip install locust
- run: >
locust -f tests/load/locustfile.py --headless
-u 20 -r 5 -t 2m -H "$BASE_URL"
--csv results --html report.html
JMeter runs without its GUI through -n, and the command-line options in its user manual include no pass/fail threshold (checked on 9 October 2026). A short step can read the results file, CSV with a success column by default, and fail the job:
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: "21"
- name: Install JMeter 5.6.3
env:
JM_BASE: https://archive.apache.org/dist/jmeter/binaries
run: curl -sSL "$JM_BASE/apache-jmeter-5.6.3.tgz" | tar -xzf -
- name: Run the test plan
run: >
apache-jmeter-5.6.3/bin/jmeter -n -t tests/load/plan.jmx
-l results.jtl -e -o report
- name: Fail above 1% errors
run: |
python3 - <<'EOF'
import csv, sys
rows = list(csv.DictReader(open("results.jtl")))
bad = sum(r["success"] != "true" for r in rows)
print(f"{bad} of {len(rows)} samples failed")
sys.exit(1 if not rows or bad > 0.01 * len(rows) else 0)
EOF
How do you make the job fail when the test misses a target?
Write the targets into the test as thresholds and let the exit code do the rest. GitHub marks a step as failed when its command exits with a non-zero code, and the job fails with it.
Start with a strict error rate and a generous latency percentile, then tighten the latency limit once you know how much your numbers move between runs. The step-by-step load testing guide covers choosing those targets.
Where should the results of each run go?
Upload them as a workflow artifact, with if: always() so a failed test still keeps its report. Artifacts are kept for 90 days by default, and retention-days shortens that.
The upload-artifact README documents the 90-day default (checked on 9 October 2026). Set if-no-files-found: error so a missing report fails loudly.
How much load can a GitHub-hosted runner generate?
Enough for a smoke test or a small regression check. A standard Linux runner has 4 CPUs and 16 GB of RAM in public repositories, 2 CPUs and 8 GB in private ones, and a job stops after 6 hours.
Those figures come from GitHub’s runner reference and Actions limits (checked on 9 October 2026), which give no figure for network bandwidth.
A job runs on one machine, so it is one load generator: if its CPU is saturated, response times include time spent waiting on the runner. Every request also leaves from that machine, so rate limiters that count per client address see one very busy client. GitHub advises against allowlisting the Azure IP ranges its Ubuntu runners use; larger runners on Team and Enterprise Cloud plans add CPU, memory and static IP addresses.
When should the load come from somewhere else?
When you need more load than one machine produces, traffic from several regions, or runs longer than the job limit.
With k6, run-k6-action runs the test on Grafana Cloud k6 when you set cloud-run-locally: false and pass K6_CLOUD_TOKEN, K6_CLOUD_PROJECT_ID and, with k6 v2, K6_CLOUD_STACK_ID. A cloud run ignores the runner’s environment by default, so add -e BASE_URL=${{ vars.STAGING_URL }} to flags (checked on 10 October 2026). Artillery’s action can call run-fargate or run-lambda to generate load from your own AWS account (both checked on 9 October 2026). The k6 vs Artillery comparison covers both.
For JMeter, LoadFocus documents a GitHub Actions integration built on its npm client. You save the test in your LoadFocus account, store the API key and team ID as secrets, and call run-test. The flags below follow the client’s own help text:
- run: npm install -g @loadfocus/loadfocus-api-client
- env:
LF_KEY: ${{ secrets.LOADFOCUS_API_KEY }}
LF_TEAM: ${{ secrets.LOADFOCUS_TEAM_ID }}
run: |
test -n "$LF_KEY"
test -n "$LF_TEAM"
loadfocus-api config set \
--apikey "$LF_KEY" --teamid "$LF_TEAM"
loadfocus-api jmeter run-test --name checkout-api \
--thresholds "avgresponse<=200,errors==0,p95<=250"
The client waits up to 1,800 seconds by default (--wait-timeout changes it), so set the job’s timeout-minutes above 30, such as 40. It exits with code 1 when a threshold fails and with 0 when it has no API key or team ID, so the test -n lines fail the step first when a secret is empty. The load comes from LoadFocus, which runs JMeter scripts from 25 cloud regions.
FAQ
How long should a load test in a pull request run? A few minutes: long enough to get past the ramp-up and collect a stable percentile, short enough that nobody waits on it.
Is it allowed to load test from GitHub-hosted runners? GitHub’s terms for Actions forbid disrupting any service and limit hosted runners to work on the repository’s own project (checked on 9 October 2026). Load testing that project’s own staging environment, without burdening GitHub’s servers, is the kind of testing those terms describe.
Can I use a self-hosted runner instead? Yes. It runs on your own hardware and network, and GitHub’s limits page allows a self-hosted job up to 5 days. It is still one load generator.
Related reading
- Load test pass/fail criteria: how to set them
- How to run a load test: a step-by-step guide
- k6 vs Artillery: which load testing tool fits your team?
The technical claims in this article have not been independently reviewed.