WebPageTest Private Instance Setup
The public WebPageTest endpoint shares its agents with the world, which means queue contention and unpredictable routing inject variance into every metric — fatal for a gate that must distinguish a real regression from network noise. A private instance puts the server and its test agents inside your own VPC or bare-metal network, giving you fixed browser versions, controlled connectivity profiles, and direct access to raw HAR and WPT data. This guide is part of the Lighthouse CI & WebPageTest Integration reference, and it covers standing up the infrastructure that makes deterministic, network-controlled gating possible.
The instance has three moving parts that must agree: a server node that accepts test requests and holds results, a pool of agents that drive real browsers, and a connectivity layer that shapes bandwidth and latency to a named profile. Misconfigure the connectivity layer and your numbers drift; misconfigure agent registration and tests silently queue forever. The sections below walk the full path — from a single-host Docker Compose stack you can validate in ten minutes, through connectivity calibration, to a scaled agent pool that keeps queue wait under control when a large GitHub Actions performance matrix bursts dozens of variants at once.
Architecture Overview
A CI runner submits a test to the server's API. The server enqueues the job in Redis and an agent polls for work, drives a real browser under a connectivity profile, then posts the result back. The server persists the raw artifacts to object storage for retention, and the runner polls the API for the median metrics it gates on. The flow is deliberately one-directional at submission time and pull-based at execution time: agents ask the server for work rather than the server pushing to agents, which is what lets you add or remove agents without reconfiguring the server.
Prerequisites and Environment
- Docker / Docker Compose on the host, or a Kubernetes/ECS target for production scale.
- A server node (the
webpagetest/serverimage) plus one or more agents (webpagetest/agent). Agents need nested virtualization or--privilegedfor traffic shaping. - Redis as the work queue between server and agents.
- Object storage (S3/GCS) for raw HAR and video retention, aligned with the retention model in Lighthouse CI Configuration & Storage.
Map credentials and endpoints through environment variables — never hardcode an API key in a compose file or workflow:
WPT_SERVER— base URL agents poll for work, e.g.http://wpt-server:4000/work/.AGENT_KEY— per-agent auth token used at registration.LOCATION_ID— browser/connectivity identifier that must match an entry inlocations.ini.
Size the host before you deploy. Each agent drives one real browser instance and, during a test, will saturate one CPU core for the duration of the trace parse. A four-core host comfortably runs two agents plus the server and Redis; beyond that the agents contend for CPU and your timings inflate, which reads as a phantom regression. If your CI runners themselves are the bottleneck, the CPU-throttling guidance in Calibrating CPU Throttling for CI Runners applies to WebPageTest agents just as it does to Lighthouse.
Configuration Reference
The server reads locations.ini to advertise which browser/connectivity combinations exist; agents register against those IDs. The annotated compose block below stands up a server, Redis, and two agents on distinct connectivity profiles.
# docker-compose.yml — server + queue + two agents
services:
wpt-server:
image: webpagetest/server:latest
ports: ["80:80", "4000:4000"] # 80 = UI, 4000 = work/API endpoint
environment:
- REDIS_HOST=redis
- SERVER_LOCATION=us-east-1
depends_on: [redis]
wpt-agent-chrome:
image: webpagetest/agent:latest
privileged: true # required for tc-based traffic shaping
environment:
- SERVER_URL=http://wpt-server:4000/work/
- LOCATION_ID=us-east-1:Chrome.Cable # must exist in locations.ini
- AGENT_KEY=${AGENT_KEY_1}
depends_on: [redis, wpt-server]
wpt-agent-firefox:
image: webpagetest/agent:latest
privileged: true
environment:
- SERVER_URL=http://wpt-server:4000/work/
- LOCATION_ID=us-east-1:Firefox.3G
- AGENT_KEY=${AGENT_KEY_2}
depends_on: [redis, wpt-server]
redis:
image: redis:7-alpine
ports: ["6379:6379"]
; locations.ini — advertises the browser/connectivity pairs agents register against
[locations]
1=us-east-1
default=us-east-1
[us-east-1]
1=us-east-1-Chrome
2=us-east-1-Firefox
label=US East Primary
[us-east-1-Chrome]
browser=Chrome
connectivity=Cable ; 5/1 Mbps, 28 ms RTT — deterministic, not the runner's real link
label=Chrome Stable
[us-east-1-Firefox]
browser=Firefox
connectivity=3G ; 1.6/0.768 Mbps, 300 ms RTT
label=Firefox 3G
The connectivity value is the determinism lever: it shapes bandwidth and latency in software so a result reflects the named profile, not the host's actual link. Every ID in the compose file's LOCATION_ID must resolve to a section in locations.ini — the us-east-1:Chrome.Cable form reads as location:browser.connectivity, and a single typo means the agent registers into a phantom location the server never advertises.
Understanding Connectivity Profiles
Connectivity is the single most consequential setting in the whole instance, because it decides what "the page" even means for a metric. The same build, tested on Cable versus Fast 3G, produces LCP values that differ by well over a second — not because the code changed, but because the transfer of the hero image and render-blocking bundle took longer on the throttled link. Choose the profile that matches the P75 field conditions of the device class you are protecting, then gate that build against that profile only. Mixing profiles across runs is how a baseline turns into noise.
The chart below plots the median LCP of one representative product page rendered on three named profiles. The measurement is a synthetic lab number, so treat it as the ceiling input for that device class rather than a field value; the P75 field target for mid-range mobile on Fast 3G that this maps to is 3500 ms.
Two practical rules follow from this. First, never gate a build on LAN or native connectivity in CI — those pass the host's real link straight through, so your numbers track your data-centre bandwidth instead of a user's phone and drift every time the host is busy. Second, keep the profile stable across the life of a baseline. If you must change a profile (say, from Fast 3G to a stricter 4G that matches shifting field data), treat it as a baseline reset and rebuild history rather than comparing across the boundary.
Step-by-Step Implementation
-
Deploy the server and queue. Bring up the server and Redis, then confirm the API answers.
docker compose up -d wpt-server redis curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4000/getLocations.phpExpected output:
200. -
Start the agents and confirm registration. Bring up the agents and verify every advertised location reports ready.
docker compose up -d wpt-agent-chrome wpt-agent-firefox curl -s "http://localhost:4000/getLocations.php?f=json" | jq '.data[].labelShort'Expected: each location listed with agents attached. An empty agent count means the
LOCATION_IDdoes not matchlocations.ini. -
Run a smoke test against a known URL to confirm the trigger, poll, and result loop works end to end, then wire the instance into CI.
curl -s -X POST "http://localhost:4000/runtest.php" \ -d '{"url":"https://staging.example.com","location":"us-east-1:Chrome.Cable","runs":1,"f":"json"}' \ -H "Content-Type: application/json" | jq '.data.id'Expected: a non-empty test ID you can then poll on
jsonResult.php.
The lifecycle behind that smoke test is worth internalizing, because every CI failure mode maps to one hop in it. The diagram below traces a single test from submission to the median the gate reads.
Once the loop is green locally, promote the same stack to a long-lived host and put it behind your CI network. Keep the smoke test as a health check that runs on a schedule, not just on demand — a private instance that answers getLocations.php with zero agents at 3 a.m. will block the first PR of the morning, and a scheduled probe surfaces that before a developer does. This is where a private WebPageTest instance dovetails with Continuous Performance Monitoring: the same server that gates PRs can run nightly synthetic tests that catch drift no single pull request introduced.
Threshold Calibration
The whole point of a private instance is reproducible connectivity, so calibrate budgets per connection profile rather than per page. Pick the profile that matches each device class's P75 field conditions, then set the lab ceiling 10 to 15 percent tighter to absorb the lab-to-field gap. The matrix below is a starting grid; weight the profiles using Device & Network Emulation Weighting.
| Connection profile | Down / Up · RTT | Device class | LCP ceiling (P75) | TTFB ceiling (P75) |
|---|---|---|---|---|
| Cable | 5 / 1 Mbps · 28 ms | Desktop | 2000 ms | 600 ms |
| 4G/LTE | 9 / 9 Mbps · 170 ms | High-end mobile | 2500 ms | 800 ms |
| Fast 3G | 1.6 / 0.75 Mbps · 300 ms | Mid-range mobile | 3500 ms | 1200 ms |
Every number in that table is a P75 target read against a specific device-plus-connection pair — the Fast 3G row protects mid-range mobile on a 1.6 Mbps down link, and the Cable row protects desktop on a 5 Mbps link. Do not port a desktop ceiling to a mobile profile; a 2000 ms LCP that is comfortable on Cable is unreachable on Fast 3G for any page with a real hero image. If you gate on more than one run per profile, gate on the median rather than a single run, and if your medians still jump run to run, the variance-reduction techniques in Statistical Noise & Flakiness Reduction apply directly. Hold a profile at a soft warning until its baseline is stable for two weeks, then promote it to a hard fail so the gate earns trust before it blocks merges.
CI Enforcement Snippet
This job triggers a test on the private instance, polls until the result is ready, and gates the merge on the median LCP. A hard breach exits non-zero and blocks the PR.
name: WPT Performance Gate
on:
pull_request:
branches: [main]
jobs:
wpt-gate:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Trigger and gate
env:
WPT_SERVER: ${{ secrets.WPT_SERVER }}
WPT_API_KEY: ${{ secrets.WPT_API_KEY }}
run: |
set -euo pipefail
ID=$(curl -s -X POST "$WPT_SERVER/runtest.php" \
-H "X-API-Key: $WPT_API_KEY" -H "Content-Type: application/json" \
-d '{"url":"https://staging.example.com","location":"us-east-1:Chrome.Cable","runs":3,"f":"json"}' \
| jq -r '.data.id')
for i in $(seq 1 36); do
R=$(curl -s "$WPT_SERVER/jsonResult.php?test=$ID&f=json")
[ "$(echo "$R" | jq -r '.statusCode')" = "200" ] && break
sleep 5
done
LCP=$(echo "$R" | jq -r '.data.median.firstView.LargestContentfulPaint')
if (( $(echo "$LCP > 2000" | bc -l) )); then
echo "::error::LCP ${LCP}ms exceeded Cable desktop P75 budget (2000ms)"; exit 1
fi
echo "LCP ${LCP}ms within budget."
Note the budget in the gate matches the profile being tested: the job runs on Chrome.Cable, so it compares against the 2000 ms Cable desktop P75 ceiling from the calibration table, not a mobile number. The 36-iteration poll with a 5-second sleep gives the test three minutes to finish before the loop falls through with a stale $R; on a busy queue, raise the count rather than the sleep so a slow start does not fail a healthy build. For the full parameter and polling reference behind this script, see Configuring WebPageTest API for Automated Testing. To decide when this is worth it over a Lighthouse-only gate, see Comparing Performance Testing Tools.
Scaling the Agent Pool
One agent serializes every test it is handed, so a matrix of five browser-connectivity variants across ten open PRs will queue behind a single agent and the last developer waits minutes for a verdict. The fix is horizontal: add agents registered to the same locations, and the pull-based queue distributes work across them automatically with no server change. The chart below shows median queue wait for a steady-state load of roughly forty submissions per hour as the pool grows.
The curve is the standard queueing shape: the first extra agent roughly halves the wait, but past the point where arrival rate is comfortably below service rate, each additional agent buys diminishing time while costing a full CPU core. Provision for your peak CI window, not your average, and cap concurrent tests per location so a burst cannot exhaust host memory. If you run WebPageTest alongside a self-hosted Lighthouse setup, the same capacity-planning logic applies to the LHCI server — see Self-Hosting the Lighthouse CI Server for the parallel sizing story.
Troubleshooting and Edge Cases
- Agents register but never pick up work means
LOCATION_IDmismatcheslocations.ini, orSERVER_URLomits the/work/path; agents poll the wrong endpoint and the queue grows unbounded. getLocations.phpshows zero agents means the agent container lacks traffic-shaping privileges; run agents with--privilegedor the requiredcap_addso connectivity shaping initializes.- Metrics drift between identical runs means the connectivity profile is
LANornative, so results track the host's real link. Pin a named profile such asCableor3Gfor determinism. - Tests time out in the queue usually means an agent crashed; add a watchdog that restarts the instance when
getLocations.phpstops returning200. - Redis queue saturates under matrix load is a capacity signal; cap concurrent tests per location, add agents per the scaling chart above, and stagger CI submissions instead of bursting all variants at once.
- Raw HAR and video fill the host disk means results are landing locally; route them to object storage and run a retention job that prunes objects older than your trend window.
- A profile passes locally but fails in CI points at host contention — the CI host is running more agents than it has spare cores, so the browser under test is starved and its LCP inflates. Reduce agents per host or move to a larger runner.
Frequently Asked Questions
Why run a private instance instead of the public endpoint?
Determinism. The public endpoint shares agents and routing, so queue contention and network variance leak into your metrics and make a gate unreliable. A private instance gives you fixed browser versions, a connectivity profile you control, and SLA-bound execution latency — the conditions a pass/fail budget gate requires.
How many agents do I need?
Start with one agent per browser and connectivity pair you gate on, then scale horizontally by queue depth. For a load near forty submissions per hour, four agents clear the queue with a median wait under ten seconds while eight buys little more. If pending tests routinely exceed roughly fifty during peak CI windows, add agents or stagger submissions.
Which connectivity profile should I gate on?
Pick the profile that matches the P75 field conditions of the device class you are protecting: Cable for desktop, Fast 3G for mid-range mobile at 1.6 Mbps down. Never gate on LAN or native connectivity in CI, because those pass the host's real link through and your numbers will drift with data-centre load rather than reflect a real user.
Where should results be stored?
Persist raw HAR and video to object storage such as S3 or GCS with a lifecycle policy, and index metadata — commit_sha, branch, environment, test_id — so runs correlate with your Lighthouse CI history. Keep the retention window aligned with the storage model in your Lighthouse CI configuration so dashboards span both tools.
Can the same instance handle both PR gating and scheduled monitoring?
Yes, and it should. The server that gates pull requests can also run nightly synthetic tests on the same profiles, which catches slow drift that no single PR introduced. Reserve a little agent headroom for the scheduled runs so they do not queue behind a burst of PR tests, and route both into the same result store for a unified trend view.