PerfLoad / CI/CD load testing
CI/CD Load Testing: Replay a Baseline Test on Every Deploy
A load test you only run by hand tends to get run before big launches and not much otherwise.
PerfLoad's runner exposes a REST API, so a pipeline can replay a test you've already tuned in the UI using only
curl and jq: no extra tooling, no test code in the repository.
You need a PerfLoad runner your pipeline can reach. The self-hosted guide covers where to run it.
How the baseline-run model works
Instead of defining the test in pipeline YAML, you define it once in PerfLoad and let the pipeline reference it by Run ID. That saved run is the baseline: its configuration is what every later run starts from.
- Configure a test in PerfLoad — request, headers, variables, users, and duration.
- Save the run. Running it once stores it in the run history.
- Store the Run ID in CI/CD as a variable, for example
BASELINE_RUN_ID. - Retrieve the configuration from the runner API with
GET /runs/:id; the test settings are under.config. - Override parameters when needed — a smaller run for every pull request, a larger one before a release.
- Execute a new run by posting that configuration to
POST /runs. - Poll for completion. A run moves through
created,running, and ends asfinished,failed, orstopped. - Retrieve the metrics from
GET /runs/:id/summary: p95, p99, requests per second, and error rate.
The script
Here is the whole flow in shell. It assumes BASELINE_RUN_ID is set in your CI environment. The
additions to the runner's documented example are set -eo pipefail, so an unreachable runner fails the
job instead of polling forever, and a guard that fails it when the run doesn't finish:
set -eo pipefail
RUNNER=http://my-runner:3000
# 1. Fetch config from the baseline run, override VUs
CONFIG=$(curl -sf "$RUNNER/runs/$BASELINE_RUN_ID" | jq '.config | .users = 50')
# 2. Start a new run
NEW_ID=$(curl -sf -X POST "$RUNNER/runs" \
-H "Content-Type: application/json" \
-d "$CONFIG" | jq -r '.id')
echo "Run ID: $NEW_ID"
# 3. Poll until the run reaches a terminal status
while true; do
STATUS=$(curl -sf "$RUNNER/runs/$NEW_ID/status" | jq -r '.status')
echo "Status: $STATUS"
[ "$STATUS" = "finished" ] || [ "$STATUS" = "failed" ] || [ "$STATUS" = "stopped" ] && break
sleep 2
done
# Don't read metrics from a run that didn't finish
[ "$STATUS" = "finished" ] || { echo "Run ended as $STATUS"; exit 1; }
# 4. Print results
curl -sf "$RUNNER/runs/$NEW_ID/summary" | jq '{
totalRequests: .metrics.http_reqs.values.count,
rps: .metrics.http_reqs.values.rate,
latencyAvg: .metrics.http_req_duration.values.avg,
latencyP95: .metrics.http_req_duration.values["p(95)"],
latencyP99: .metrics.http_req_duration.values["p(99)"],
errorRate: .metrics.http_req_failed.values.rate
}'
Override parameters per pipeline stage
Because the config is just JSON, jq can change any field before you post it. A quick smoke test on
each change and a heavier run on release use the same baseline:
# Smoke test
CONFIG=$(curl -s $RUNNER/runs/$BASELINE_RUN_ID | jq '.config | .users = 5 | .duration = "30s"')
# Load test
CONFIG=$(curl -s $RUNNER/runs/$BASELINE_RUN_ID | jq '.config | .users = 100 | .duration = "5m"')
Why persisted run history matters here
A baseline Run ID is only useful if it still exists next month. When the runner is started with a named
volume (-v perfload-runs:/app/runs), run history lives outside the container, so replacing the
container with a newer image doesn't lose your baseline Run IDs. On Kubernetes, the equivalent is a
PersistentVolumeClaim mounted at /app/runs; without one, a pod restart clears the history and
your stored Run ID stops resolving. The installation guide has both setups.
Practical notes for pipelines
- Reachability. The job needs network access to the runner, and the runner needs access to the service under test. Where the runner should live is a design decision; see self-hosted load testing.
- Access control. The runner API doesn't document built-in authentication, so restrict who can reach it with your network or ingress controls rather than exposing it openly.
- Size the runner. Each
POST /runsstarts its own k6 process and runs aren't queued, so overlapping high-VU runs compete for the same CPU. Serialize heavy stages if that matters. - Compare against the baseline. To keep a report as a pipeline artifact,
POST /runs/compare/report.pdfwith{"ids": ["<baseline>", "<new>"]}returns a comparison PDF. PDF generation runs headless Chromium, so leave memory headroom on the runner.
FAQ
Where do I get the Run ID?
Run the test once in the Workbench or Dashboard and copy the ID from the run's entry in the Dashboard's history.
Does this fail the build when latency regresses?
Not by itself. The script reports metrics and fails if the run doesn't finish. To gate on latency, compare a value from the summary, such as p95, to a limit in a later step. The GitHub Actions example shows one way.
Can I use this outside GitHub Actions?
Yes. It is plain shell plus HTTP calls, so it works in any CI system that can run curl and jq.
Do I need to install anything in the pipeline?
Only curl and jq, which most CI images already include.
Related guides
Make load testing part of every deploy
Tune a test once in PerfLoad, then let the pipeline replay it.
Automate Your Load Tests →