Load Testing in GitHub Actions

There are two practical ways to load test from GitHub Actions: run an open-source tool such as k6 directly on the runner and fail the job on thresholds, or use the workflow to trigger a hosted load test with an API token stored as a repository secret and gate on its result. Runner-based tests suit smoke tests and small performance gates on pull requests; anything that needs real load should come from outside the runner. Run a smoke test on PRs, a performance gate on main, and a longer test nightly with a cron schedule.
Two patterns, and when each fits
| (a) Tool inside the runner | (b) Trigger a hosted test | |
|---|---|---|
| Where load comes from | The GitHub-hosted (or self-hosted) runner | The provider's load generators |
| Realistic ceiling | Limited by a 2–4 vCPU class VM on shared network | Whatever your plan allows |
| Test definition | Script in the repo (versioned with the code) | Saved test in the provider, referenced from the workflow |
| Results | Console summary, artifacts you upload | Provider dashboard, history, run comparison |
| Best for | PR smoke tests, small perf gates, teams already using k6 | Main-branch gates at real load, nightly and soak tests |
Both are reasonable, and many teams use both: k6 on the runner for a 60-second PR check, and a hosted test for the bigger runs. For the broader strategy, see load testing in CI/CD and whether CLI tools are enough for CI/CD.
Pattern (a): k6 inside the runner
k6 is a good fit here: a single binary, JavaScript test scripts, and thresholds that set the process exit code. When any threshold fails, k6 run exits with code 99, which fails the step.
The test script
// tests/perf/smoke.js
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE_URL = __ENV.BASE_URL;
export const options = {
scenarios: {
smoke: {
executor: 'constant-arrival-rate', // fixed RPS, not closed-loop VUs
rate: Number(__ENV.RATE || 20), // iterations per second
timeUnit: '1s',
duration: __ENV.DURATION || '60s',
preAllocatedVUs: 20,
maxVUs: 100,
},
},
thresholds: {
http_req_failed: ['rate<0.01'], // < 1% errors
http_req_duration: ['p(95)<400', 'p(99)<1000'], // ms
'http_req_duration{name:checkout}': [{ threshold: 'p(95)<800', abortOnFail: true }],
},
};
export default function () {
const list = http.get(`${BASE_URL}/api/products?limit=20`, { tags: { name: 'list' } });
check(list, { 'list 200': (r) => r.status === 200 });
const res = http.post(`${BASE_URL}/api/checkout/quote`,
JSON.stringify({ sku: 'TEST-001', qty: 1 }),
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'checkout' } });
check(res, { 'quote 200': (r) => r.status === 200 });
sleep(0.5);
}
The constant-arrival-rate executor keeps the request rate fixed even if the server slows down, which avoids hiding latency the way a closed loop does (see coordinated omission).
The workflow
# .github/workflows/perf-smoke.yml
name: perf-smoke
on:
pull_request:
paths: ['src/**', 'tests/perf/**']
concurrency:
group: perf-smoke-${{ github.event.pull_request.number }}
cancel-in-progress: true # a new push cancels the old run
permissions:
contents: read
jobs:
k6-smoke:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: grafana/setup-k6-action@v1
- name: Run k6 smoke test
env:
BASE_URL: ${{ vars.PREVIEW_BASE_URL }}
RATE: '20'
DURATION: '60s'
run: k6 run --summary-export=summary.json tests/perf/smoke.js
- name: Upload summary
if: always()
uses: actions/upload-artifact@v4
with:
name: k6-summary
path: summary.json
Runner limits to respect
- Small VMs. Standard GitHub-hosted Linux runners are in the 2–4 vCPU class depending on repository visibility and plan. Larger runners exist at extra cost. If the runner's CPU is pegged, your latency numbers include the generator's own queueing.
- Shared network. Runners sit on shared cloud infrastructure with variable network paths and changing IP addresses. Run-to-run noise is higher than on dedicated hardware, so set gates with some margin and compare trends, not single runs.
- WAF and rate limits. Runner IPs come from large shared cloud ranges. A WAF may challenge them, and allowlisting is impractical. Test a staging host that is not behind bot protection, or use a self-hosted runner with a fixed egress IP.
- Time limits. Jobs on GitHub-hosted runners are capped at 6 hours, and you pay for every minute on private repos. Soak tests belong elsewhere.
Pattern (b): trigger a hosted test from the workflow
Here the workflow does not generate load. It authenticates to a hosted service, starts a saved test, and fails the job if the test breached its thresholds. The load comes from the provider's workers, so the runner size does not matter.
With LoadTester, API tokens for CI/CD are included in the Starter ($19/month) and Pro ($49/month) plans. Create a token in the app (tokens start with wst_), then add it as a repository secret named LOADTESTER_TOKEN under Settings → Secrets and variables → Actions. Save the test itself in the app with its thresholds (for example p95 < 400 ms and error rate < 2%, with auto-stop on breach), and make sure the target domain is verified.
To start the run, call the run endpoint documented on the API page in the app. The URL and response fields below are placeholders, not real paths; copy the actual ones from the API docs:
- name: Trigger saved load test
env:
LOADTESTER_TOKEN: ${{ secrets.LOADTESTER_TOKEN }}
# PLACEHOLDER: set this repository variable to the run endpoint
# for your saved test, exactly as shown in the app's API docs.
LOADTESTER_RUN_URL: ${{ vars.LOADTESTER_RUN_URL }}
run: |
curl --fail-with-body -sS -X POST "$LOADTESTER_RUN_URL" \
-H "Authorization: Bearer $LOADTESTER_TOKEN" \
-o run.json
cat run.json
- name: Gate on result
run: |
# PLACEHOLDER: wait for completion and read the pass/fail field
# using the status mechanism described in the API docs, then:
# exit 1 if the thresholds were breached
echo "See API docs for the status and result fields"
Two alternatives avoid polling from the workflow entirely: a scheduled (cron) test in the app with Slack or email alerts and regression notifications against a baseline, or a completion webhook pointed at your own endpoint. These work well for nightly runs where nobody is waiting on a PR.
Deploy markers with the annotations endpoint
When a longer test is running while you deploy (for example a scheduled 10-minute run against staging), a marker on the chart at the moment of the deploy makes cause and effect obvious. LoadTester has an annotations endpoint for this:
POST https://app.loadtester.org/api/annotations/push
Authorization: Bearer $LOADTESTER_TOKEN
Content-Type: application/json
{"test_id":"<uuid>","ts_seconds":45,"label":"Deploy v2.1"}
test_id is the UUID of the test, ts_seconds is where on the test's timeline the marker goes (seconds from the start), and label is the text shown on the chart. As a workflow step after your deploy job:
- name: Push deploy marker
if: success()
env:
LOADTESTER_TOKEN: ${{ secrets.LOADTESTER_TOKEN }}
TEST_ID: ${{ vars.LOADTESTER_TEST_ID }}
TEST_STARTED_AT: ${{ needs.start-test.outputs.started_at }} # epoch seconds
run: |
TS=$(( $(date +%s) - TEST_STARTED_AT ))
curl --fail-with-body -sS -X POST \
https://app.loadtester.org/api/annotations/push \
-H "Authorization: Bearer $LOADTESTER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"test_id\":\"$TEST_ID\",\"ts_seconds\":$TS,\"label\":\"Deploy ${GITHUB_SHA::7}\"}"
If the p95 line steps up right after the marker, you have your regression and the commit that caused it. The performance regression testing guide covers how to set the comparison baseline.
When to run: PR, main, nightly
| Trigger | Purpose | Load and duration | Fails the build when |
|---|---|---|---|
pull_request | Smoke: did this change break or badly slow a hot endpoint? | Low RPS, 30–60 s, on the runner | Errors > 1% or p95 well above baseline |
push to main | Performance gate before or after deploy to staging | Target RPS, 5–10 min, hosted | Thresholds breached or regression vs baseline |
schedule (nightly) | Trends, slow leaks, data growth | Target RPS or soak, as long as your plan allows | Alert rather than block; open an issue |
on:
push:
branches: [main]
schedule:
- cron: '17 2 * * *' # 02:17 UTC daily; avoid minute 0, which is busiest
workflow_dispatch: {} # manual runs with the same workflow
Schedules run in UTC, only from the default branch, and on a best-effort basis: they can start late when GitHub is busy. In public repositories, scheduled workflows are disabled automatically after 60 days without repository activity.
Concurrency, environments and secrets
One test per target at a time
Two load tests against the same staging environment at once produce two invalid results. Use a concurrency group keyed on the target, not the branch, and do not cancel a gate that is already running:
concurrency:
group: loadtest-staging
cancel-in-progress: false
This also keeps you within your provider's concurrent test limit (LoadTester Pro allows two concurrent tests; Free and Starter allow one).
Protect the environment
jobs:
perf-gate:
runs-on: ubuntu-latest
environment: perf-staging # environment-scoped secrets and rules
timeout-minutes: 20
In Settings → Environments, restrict perf-staging to the main branch and store LOADTESTER_TOKEN there instead of at repository level. For anything that touches production, add required reviewers so a person approves each run.
Secrets hygiene
- Pass tokens through
env:and reference them as shell variables, never inline${{ secrets.X }}inside a script string that might be echoed. - Pull requests from forks do not receive secrets. Do not "fix" that with
pull_request_targetplus a checkout of the PR head; that runs untrusted code with your token. - Set
permissions: contents: readat workflow level unless a job needs more. - Rotate the token if a log ever shows it; GitHub masks registered secrets but not values derived from them.
Add a 60-second k6 smoke test with http_req_failed and p(95) thresholds on PRs; add a main-branch gate that triggers a hosted test with a secret token in a protected environment; add a nightly cron at an odd minute; put all of them in one concurrency group per target. Compare the options in LoadTester vs k6 if you are choosing between them.
FAQ
Can I run a real load test on a GitHub-hosted runner?
You can run small ones. Standard GitHub-hosted runners are in the 2-4 vCPU class on shared network infrastructure, which is enough for smoke tests and performance gates at modest load. For tests that need thousands of RPS or a stable network path, run the load from dedicated infrastructure or a hosted service and use the workflow only to trigger and gate.
How do I make the workflow fail when the load test fails?
Make the step exit non-zero. k6 exits with code 99 when any threshold fails, which fails the step and the job. For a hosted test, read the result returned by the provider's API and exit 1 when thresholds were breached.
Why does my scheduled load test workflow not run on time?
GitHub runs scheduled workflows on a best-effort basis and they can be delayed during periods of high load, especially at the top of the hour. Pick an unusual minute such as 17 past. Scheduled workflows also only run from the default branch, and in public repositories they are disabled after 60 days without repository activity.
Are secrets available to load tests on pull requests from forks?
No. Workflows triggered by pull_request from a fork do not receive repository secrets, so a test that needs an API token will fail there. Do not work around this with pull_request_target plus a checkout of the fork's code, because that runs untrusted code with access to your secrets.
Should load tests run against production from CI?
Rarely. Run CI load tests against a production-like staging or preview environment. If you do test production, use a protected environment with required reviewers, a small load profile, thresholds with auto-stop, and a schedule outside peak hours.