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.

WebPageTest private instance data flow A CI runner submits to the WebPageTest server, which enqueues the job in Redis. An agent polls the queue, drives a real browser under a named connectivity profile, and the result is stored in an object-storage bucket while the runner polls for the median. submit enqueue poll store CI runner API client WPT server :80 UI · :4000 API Redis work queue Agent · browser Cable / 3G profile Result store S3 / GCS runner polls jsonResult.php for the median
The runner submits via the API; the server queues work in Redis, an agent drives a real browser under a named connectivity profile, and results persist to object storage while the runner polls back for the median it gates on.

Prerequisites and Environment

  • Docker / Docker Compose on the host, or a Kubernetes/ECS target for production scale.
  • A server node (the webpagetest/server image) plus one or more agents (webpagetest/agent). Agents need nested virtualization or --privileged for 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 in locations.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.

Median lab LCP by connectivity profile The same page renders in 1800 milliseconds on Cable desktop, 2300 milliseconds on 4G LTE mobile, and 3400 milliseconds on Fast 3G mobile, showing how the connectivity profile dominates the metric. 0 1000 2000 3000 4000 median lab LCP (ms) Cable · desktop 1800 ms 4G LTE · mobile 2300 ms Fast 3G · mobile 3400 ms
One build, three profiles: connectivity alone moves median LCP by 1.6 seconds, which is why every gate must pin a single named profile per device class.

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

  1. 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.php

    Expected output: 200.

  2. 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_ID does not match locations.ini.

  3. 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.

Test lifecycle across runner, server, queue, and agent Six steps: the runner submits to the server, the server enqueues in Redis, the agent polls for work, drives the browser, posts the result to the server, and the runner polls for the median. CI runner WPT server Redis queue Agent 1 POST runtest.php 2 enqueue job 3 poll /work/ 4 drive browser 5 POST result 6 poll median
Every CI failure maps to one hop: a stuck step 3 means agent registration is wrong, a timeout at step 6 means the agent crashed mid-render at step 4.

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.

Median queue wait versus agent-pool size Queue wait falls from 48 seconds with one agent to 22 seconds with two, 9 seconds with four, and 4 seconds with eight, with sharply diminishing returns beyond four agents. 0 10 20 30 40 50 median queue wait (s) 48 s 1 agent 22 s 2 agents 9 s 4 agents 4 s 8 agents
Queue wait falls steeply from one to four agents, then flattens — four agents clear this load with headroom, and the eighth agent buys little but costs a full core.

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_ID mismatches locations.ini, or SERVER_URL omits the /work/ path; agents poll the wrong endpoint and the queue grows unbounded.
  • getLocations.php shows zero agents means the agent container lacks traffic-shaping privileges; run agents with --privileged or the required cap_add so connectivity shaping initializes.
  • Metrics drift between identical runs means the connectivity profile is LAN or native, so results track the host's real link. Pin a named profile such as Cable or 3G for determinism.
  • Tests time out in the queue usually means an agent crashed; add a watchdog that restarts the instance when getLocations.php stops returning 200.
  • 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.