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.

Architecture Overview

A CI runner submits a test to the server's API. The server enqueues the job (via Redis) and an agent polls for work, drives a real browser under a connectivity profile, then posts the result back. Results land in object storage for retention, and the runner polls the API for the median metrics it gates on.

WebPageTest private instance: server, queue, agents, browsers, storage A CI runner calls the server API to submit a test. The server enqueues the job in Redis. Agents poll the server for work and drive real Chrome and Firefox browsers under a connectivity profile, then post results back. The server persists results to an object-storage bucket and returns median metrics to the runner. CI runner API client WPT server :80 UI · :4000 API locations.ini Redis work queue agent · Chrome connectivity: Cable agent · Firefox connectivity: 3G result store S3 / GCS bucket
The runner submits via the API; the server queues work in Redis, agents drive real browsers under a named connectivity profile, and results persist to object storage for retention and trend analysis.

Prerequisites & 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.

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

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

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–15% 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
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

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 > 3500" | bc -l) )); then
            echo "::error::LCP ${LCP}ms exceeded hard budget (3500ms)"; exit 1
          fi
          echo "LCP ${LCP}ms within budget."

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.

Troubleshooting & Edge Cases

  • Agents register but never pick up workLOCATION_ID mismatches locations.ini, or SERVER_URL omits the /work/ path; agents poll the wrong endpoint and the queue grows.
  • getLocations.php shows zero agents → 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 → the connectivity profile is LAN/native, so results track the host's real link. Pin a named profile (Cable, 3G) for determinism.
  • Tests time out in the queue → an agent crashed; add a watchdog that restarts the instance when getLocations.php stops returning 200.
  • Redis queue saturates under matrix load → cap concurrent tests per location and stagger CI submissions instead of bursting all variants at once.
  • Raw HAR/video fills the host disk → route results to object storage and run a retention job that prunes objects older than your trend window.

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/connectivity pair you gate on, then scale horizontally by queue depth. If pending tests routinely exceed roughly 50 during peak CI windows, add agents or stagger submissions. One agent can serialize many tests, but parallel CI matrices will queue behind it.

Where should results be stored?

Persist raw HAR and video to object storage (S3/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.