By the Proxy001 Engineering Team — This post draws from our experience migrating cross-region E2E infrastructure across a distributed test setup targeting multiple production markets.
Why Your E2E Tests Pass Locally but Fail in CI
The failure looks random — until you check where your CI runner is located.
We first hit this pattern on a Tuesday: our Frankfurt CI node had been failing the checkout flow at the currency selector step for three days straight. Local runs passed every time. The team spent the better part of two days auditing the component — no code changes, no dependency updates, no meaningful diff between environments. The real cause turned out to be the runner's IP: it was registered to a Hetzner datacenter block, and our CDN was routing it to a UK edge node that served a promotional banner replacing the EUR currency selector with a static campaign element. The DOM node our test was asserting on simply wasn't there from that IP.
After switching to residential proxies for our EU test traffic, that failure — and a dozen variations of it across other regions — stopped occurring. Pass rate on geo-sensitive flows went from around 70% to consistent green in under a week.
This is geo-blocking-induced test flakiness, and it's one of the most frustrating kinds because the failure doesn't point at the code. The diff between local and CI is where the HTTP request originates, not what the test does. Residential proxies fix this by routing your test traffic through real home ISP addresses in specific countries, making your test runner look like a real user in that market.
What Geo-IP Actually Does to Your Tests
Three separate mechanisms turn tests geo-dependent, and they produce failures that look like race conditions or intermittent network issues but are actually deterministic.
CDN geo-split. Major CDNs route requests to region-specific edge nodes that serve different asset bundles, localized content, or entirely different page layouts. A product page might return a UK-specific promotional banner from the London edge node and a different layout from the US node — same code, different IP, different DOM.
Application-side IP detection. Many apps parse X-Forwarded-For or call an IP geolocation API to decide which language, currency, or regulatory compliance flows to render. If your test runner's IP resolves to the wrong country, the app routes it into a flow you never wrote assertions for.
Datacenter IP reputation filtering. A significant number of websites — especially those with bot-mitigation systems — automatically flag requests from datacenter IP ranges (AWS, GCP, Azure, Hetzner) and respond with a 403, a CAPTCHA, or a degraded page variant. Your test never reaches the content it's trying to assert on. Same IP range, same failure, every time.
Datacenter vs. Residential Proxies: Why It Matters for Test Stability
The core difference is IP reputation and ASN classification.
Datacenter proxies route through IPs registered to cloud hosting providers. Their ASNs (Autonomous System Numbers) are publicly known and widely blocked at the infrastructure level — they're exactly the traffic pattern that anti-bot systems are built to catch. Residential proxies use addresses issued by consumer ISPs — the same ranges your actual users get. To geo-detection and bot-mitigation systems, they're indistinguishable from organic traffic.
| Datacenter Proxy | Residential Proxy | |
|---|---|---|
| IP source | Cloud hosting ASN | Consumer ISP ASN |
| Geo-detection accuracy | Unreliable (commonly flagged as VPN/proxy) | Accurate (real ISP geo assignment) |
| Bot detection risk | High | Low |
| Typical latency | ~5–30ms avg (consistent) | ~20–150ms avg (variable by ISP routing) |
| Best fit | Non-sensitive scraping, internal tool testing | Geo-accurate E2E tests, localization QA |
For E2E testing where you need to accurately replicate what a real user in Frankfurt, Tokyo, or São Paulo sees, residential proxies are the right tool. The latency increase is real but predictable — you account for it once in your timeout settings rather than debugging phantom failures indefinitely.
Step-by-Step: Integrating Residential Proxies into Your Test Framework
The integration path differs meaningfully between frameworks. Playwright gives you the most control — proxy config works at the project and context level. Cypress operates at the process level via environment variables, which creates an important limitation covered below. Selenium requires the most manual handling for authenticated proxies.
Playwright
Playwright's native proxy support is the cleanest option for geo-targeted E2E tests. You can set proxy configuration per project in playwright.config.ts, which lets you run multi-region tests in parallel from a single suite — each project routes through a different country's IP pool. The full proxy API reference is available in Playwright's official BrowserContext documentation.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'EU-tests',
use: {
...devices['Desktop Chrome'],
proxy: {
server: `http://${process.env.PROXY_HOST_EU}`,
username: process.env.PROXY_USER,
password: process.env.PROXY_PASS,
},
locale: 'de-DE',
geolocation: { longitude: 13.4, latitude: 52.5 }, // Berlin
permissions: ['geolocation'],
},
testMatch: '**/eu/**',
},
{
name: 'US-tests',
use: {
...devices['Desktop Chrome'],
proxy: {
server: `http://${process.env.PROXY_HOST_US}`,
username: process.env.PROXY_USER,
password: process.env.PROXY_PASS,
},
locale: 'en-US',
geolocation: { longitude: -87.6, latitude: 41.8 }, // Chicago
permissions: ['geolocation'],
},
testMatch: '**/us/**',
},
],
});
Three things worth noting. First, PROXY_HOST_EU should include the port — typically something like gate.yourprovider.com:7777. The server value uses http:// as the scheme even when your target pages are HTTPS; the proxy connection itself is HTTP and it tunnels HTTPS traffic via CONNECT. Second, combining proxy with locale and geolocation gives you the full geo-simulation stack: the proxy sets the outbound IP, the locale affects Accept-Language headers and Intl API behavior, and geolocation handles navigator.geolocation calls. Third, if any of these tests involve login flows or session state, read the sticky session section before running them.
To override the proxy for one specific test without touching the project config:
test('German pricing page shows EUR', async ({ browser }) => {
const context = await browser.newContext({
proxy: {
server: `http://${process.env.PROXY_HOST_EU}`,
username: process.env.PROXY_USER,
password: process.env.PROXY_PASS,
},
});
const page = await context.newPage();
await page.goto('https://yourapp.com/pricing');
await expect(page.locator('[data-testid="price"]')).toContainText('€');
await context.close();
});
Cypress
Cypress doesn't support per-test or per-project proxy configuration at the network level — it proxies all browser traffic through system environment variables (official proxy configuration docs). Set HTTPS_PROXY before cypress run and Cypress routes everything through it.
HTTPS_PROXY=http://user:pass@gate.yourprovider.com:7777 \
NO_PROXY=localhost,127.0.0.1 \
npx cypress run
The NO_PROXY value matters. Without it, Cypress's internal communication between the test runner process and the browser also goes through the proxy, causing connection failures.
Per-region testing in Cypress: You can't switch geo within a single cypress run. The practical workaround is running the suite multiple times with different proxy endpoints — one run per region. Here's a shell wrapper that handles that cleanly:
#!/bin/bash
# scripts/run-geo-tests.sh
# Runs the full Cypress suite once per region, serially.
# Trade-off: 3 regions × 5 min/run = 15 min total wall clock time.
# Fine for nightly pipelines; too slow for per-PR runs.
set -e
REGIONS=("eu" "us" "jp")
for REGION in "${REGIONS[@]}"; do
echo "--- Running Cypress suite for region: $REGION ---"
HTTPS_PROXY="http://${PROXY_USER}:${PROXY_PASS}@${REGION}-gate.yourprovider.com:7777" \
NO_PROXY="localhost,127.0.0.1" \
npx cypress run --spec "cypress/e2e/**" --env GEO_REGION="${REGION}"
done
Inside your tests, Cypress.env('GEO_REGION') lets you branch assertions by region if needed. This approach is serial — three regions at five minutes each means fifteen minutes total. Acceptable for a nightly run, painful for per-PR pipelines. If parallel multi-region testing matters for your iteration speed, Playwright's project-based architecture is genuinely the right tool for it.
Selenium / WebDriver
For authenticated residential proxies with Selenium, the most reliable approach is IP whitelisting: register your CI runner's outbound IP with your proxy provider, and the proxy grants access without credentials in each request. This sidesteps Chrome's inconsistent handling of inline proxy auth.
IP-whitelisted setup in Python (cleanest path when available):
# test_geo_ipwhitelist.py
import os
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
options = Options()
# PROXY_HOST = gate.yourprovider.com:7777 (IP-whitelisted — no credentials needed)
options.add_argument(f'--proxy-server=http://{os.environ["PROXY_HOST"]}')
driver = webdriver.Chrome(service=Service(), options=options)
driver.get('https://yourapp.com')
driver.quit()
If your CI runners have dynamic IPs and can't use whitelisting, the standard solution is a Chrome extension that handles onAuthRequired at the browser level. This is the same pattern used across major proxy providers and is publicly documented:
# test_geo_selenium.py — proxy auth via Chrome Manifest V3 extension
import os
import zipfile
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
PROXY_HOST = os.environ["PROXY_HOST"] # e.g., gate.yourprovider.com
PROXY_PORT = int(os.environ["PROXY_PORT"]) # e.g., 7777
PROXY_USER = os.environ["PROXY_USER"]
PROXY_PASS = os.environ["PROXY_PASS"]
manifest_json = """
{
"name": "Proxy Auth Extension",
"version": "1.0.0",
"manifest_version": 3,
"permissions": ["proxy", "storage", "webRequest", "webRequestAuthProvider"],
"host_permissions": ["<all_urls>"],
"background": { "service_worker": "background.js" },
"action": { "default_title": "Proxy Auth" }
}
"""
background_js = f"""
chrome.runtime.onInstalled.addListener(() => {{
chrome.proxy.settings.set({{
value: {{
mode: "fixed_servers",
rules: {{
singleProxy: {{ scheme: "http", host: "{PROXY_HOST}", port: {PROXY_PORT} }},
bypassList: ["localhost"]
}}
}},
scope: "regular"
}}, () => {{}});
}});
chrome.webRequest.onAuthRequired.addListener(
function(details) {{
return {{ authCredentials: {{ username: "{PROXY_USER}", password: "{PROXY_PASS}" }} }};
}},
{{ urls: ["<all_urls>"] }},
["blocking"]
);
"""
plugin_path = "proxy_auth_plugin.zip"
with zipfile.ZipFile(plugin_path, "w") as zp:
zp.writestr("manifest.json", manifest_json)
zp.writestr("background.js", background_js)
options = Options()
options.add_extension(plugin_path)
options.add_argument(f"--proxy-server=http://{PROXY_HOST}:{PROXY_PORT}")
driver = webdriver.Chrome(service=Service(), options=options)
driver.get("https://yourapp.com")
# your test logic here
driver.quit()
import os as _os
_os.remove(plugin_path) # clean up the temp zip
The same manifest.json + background.js pattern applies in Java (via AdmZip) and Node.js — if you need those variants, the structure is identical, only the language binding differs. Note that some proxy providers publish a pre-built extension zip you can load directly; check your provider's integration documentation for a download link.
When to Use Sticky Sessions Instead of Rotating Proxies
The short answer: if your test has a login step, use a sticky session. If it doesn't, rotating is fine.
With a rotating proxy, each new connection can come from a different IP address. Most session management systems — especially those with fraud detection — treat a mid-session IP change as suspicious and invalidate the session token. Your test logs in successfully, then gets bounced back to the login page two clicks later. That looks like a race condition or UI timing issue, but it's the session getting killed by the IP change.
A sticky session holds one IP for a configurable time window. For E2E tests, set the TTL to cover your longest test flow with a 20% buffer. If your most complex checkout test takes 90 seconds end-to-end, a 2-minute sticky TTL is sufficient. Most residential proxy providers implement sticky sessions through session identifiers in the connection endpoint — the exact format varies by provider, so check your provider's API documentation, but the general pattern looks like this:
// playwright.config.ts — sticky session example
// The username format (session suffix) is provider-specific.
// This shows the common pattern; verify the exact syntax in your provider's docs.
const sessionId = `run_${process.env.GITHUB_RUN_ID ?? 'local'}_${Date.now()}`;
export default defineConfig({
use: {
proxy: {
server: `http://${process.env.PROXY_HOST}`,
username: `${process.env.PROXY_USER}-session-${sessionId}`,
password: process.env.PROXY_PASS,
},
},
});
Using GITHUB_RUN_ID as part of the session ID ensures each CI run gets a distinct sticky IP rather than colliding with a parallel run.
Decision rule:
- No login, no cart state, no multi-step form → rotating proxy
- Has login, persistent cart, or any session-dependent flow → sticky session, TTL = longest test duration × 1.2
Verify Your Proxy Is Actually Working
Before pushing this to CI, confirm geo is working with a dedicated smoke test. It takes under two minutes to run and will save you from chasing a misconfigured setup later.
Prerequisites:
- Proxy credentials (or whitelisted IP) from your provider
- Playwright installed (
npm init playwright@latest) - Environment variables set locally:
PROXY_HOST,PROXY_USER,PROXY_PASS
The geo-check spec:
// tests/geo-check.spec.ts
import { test, expect } from '@playwright/test';
test('proxy IP resolves to target country', async ({ page }) => {
const response = await page.goto('https://ipapi.co/json/');
const text = await page.evaluate(() => document.body.innerText);
const data = JSON.parse(text);
console.log(`Resolved country: ${data.country_code}, IP: ${data.ip}`);
// Update 'DE' to your target country code (US, JP, BR, etc.)
expect(data.country_code).toBe('DE');
});
Run it against your EU project: npx playwright test tests/geo-check.spec.ts --project=EU-tests
Success criteria:
- Test passes without timeout
- Console output shows an IP in your target country (
country_code: "DE") - The IP shown is not your CI runner's native IP address
If the test passes but country_code still shows your runner's home country, the proxy is being silently bypassed. The most common cause is a PROXY_HOST value missing the http:// prefix or an incorrect port number — fix those before debugging anything else.
A note on scope: Routing test traffic through residential proxies is legitimate testing practice, but your test targets still receive real requests. Keep these tests pointed at your own applications or staging environments. Avoid running load-heavy test suites through residential IPs against third-party services — it consumes real users' bandwidth allocations and may violate those services' terms of service. If your tests hit production endpoints you don't own (for localization checks, for example), verify your testing agreement covers automated traffic from external IPs.
Keeping Proxy Credentials Safe in CI/CD
Never commit proxy credentials to your repository. Store them as CI secrets and inject them as environment variables at runtime — no credential ever touches your codebase.
GitHub Actions:
- Go to your repository → Settings → Secrets and variables → Actions → New repository secret.
- Add three secrets:
PROXY_HOST,PROXY_USER,PROXY_PASS. (GitHub's secrets documentation covers access controls and secret rotation.) - Reference them in your workflow:
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
env:
PROXY_HOST: ${{ secrets.PROXY_HOST }}
PROXY_HOST_EU: ${{ secrets.PROXY_HOST_EU }}
PROXY_HOST_US: ${{ secrets.PROXY_HOST_US }}
PROXY_USER: ${{ secrets.PROXY_USER }}
PROXY_PASS: ${{ secrets.PROXY_PASS }}
run: npx playwright test
GitLab CI:
Add the variables under Settings → CI/CD → Variables (mark each as masked). Then reference them in .gitlab-ci.yml:
# .gitlab-ci.yml
e2e:
image: mcr.microsoft.com/playwright:v1.44.0-jammy
variables:
PROXY_HOST: $PROXY_HOST
PROXY_USER: $PROXY_USER
PROXY_PASS: $PROXY_PASS
script:
- npm ci
- npx playwright test
Add a .env.example file to your repository with placeholder values:
# .env.example — copy to .env and fill in real values (never commit .env)
PROXY_HOST=gate.yourprovider.com:7777
PROXY_HOST_US=us.yourprovider.com:7777
PROXY_HOST_EU=eu.yourprovider.com:7777
PROXY_USER=your_username
PROXY_PASS=your_password
The 5 Errors You'll Actually Hit
1. 407 Proxy Authentication Required
Credentials are wrong, or you're providing username/password when the provider expects IP whitelisting — not both. Verify with a manual curl first: curl -x http://user:pass@host:port https://ipapi.co/json/. If that fails, check your provider's dashboard for an IP Whitelist section and confirm your CI runner's outbound IP is registered there.
2. ERR_TUNNEL_CONNECTION_FAILED
Almost always caused by using https:// in the proxy server URL. Residential proxies expect http:// for the proxy connection itself, regardless of the target site's protocol — the HTTPS tunneling happens via CONNECT over that HTTP connection. Change server: 'https://gate.yourprovider.com:7777' to server: 'http://gate.yourprovider.com:7777' and retry.
3. SSL certificate errors (ERR_CERT_AUTHORITY_INVALID)
The proxy is intercepting TLS. For test environments where you're not specifically validating SSL certificates, suppress this per-project:
use: {
ignoreHTTPSErrors: true,
proxy: { server: '...', username: '...', password: '...' },
}
Scope it only to proxy-targeted projects — don't enable it globally in suites that include SSL validation tests.
4. Timeout spikes
Residential proxies route through real consumer ISP connections — average round-trip latency runs 20–150ms, compared to 5–30ms for most datacenter connections. The gap is predictable once you account for it. To find your correct timeout values: run your slowest navigation test without a proxy and note the actual duration from Playwright traces (or use --trace on for a run). Then set navigationTimeout to 3× that baseline for proxy-routed projects.
{
name: 'EU-tests',
use: {
actionTimeout: 15_000, // adjust if your baseline actions exceed ~5s
navigationTimeout: 45_000, // 3× a ~15s bare-metal navigation baseline
proxy: { ... },
},
}
5. IP banned or session drops mid-test
Unexpected auth redirects or login prompts mid-flow usually mean your sticky session TTL expired before the test finished. Check your provider's dashboard for the maximum TTL available on your current plan — some entry-tier plans cap sticky sessions at 60–120 seconds, which isn't enough for multi-step checkout flows. Either upgrade to a plan with a longer TTL or split long flows into shorter test cases that each complete within the window.
Choosing a Residential Proxy Provider for Testing
Generic proxy selection criteria (IP pool size, uptime SLA) don't tell you much for E2E testing specifically. These are the dimensions that actually affect whether your test suite works reliably:
- Country-level coverage and regional reach: Verify the provider has IPs in the specific countries your app serves — not just a headline country count.
- Sticky session maximum TTL: Confirm the TTL ceiling on the plan you're evaluating, not just "sticky sessions supported." Some plans cap at 1–2 minutes; complex flows need more.
- SOCKS5 support: Selenium + ChromeDriver integrates more cleanly with SOCKS5 in some configurations. Verify this is available if your stack is Selenium-heavy.
- Concurrent connection allowance: Playwright with 4 workers needs at least 4 simultaneous proxy connections. Add ~50% headroom for retries.
- Framework integration documentation: Providers that publish working code examples for your specific framework — not just generic setup guides — save meaningful setup time.
Proxy001 covers all of these for testing use cases: 100M+ residential IPs across 200+ regions, with geo-targeting options that let you specify the target region for each proxy endpoint — check proxy001.com for the current granularity options. Proxy001 also publishes integration documentation for major testing frameworks — verify current coverage at proxy001.com or your account dashboard after sign-up. They offer a trial option so you can run the geo-check spec above against your actual test targets before committing to a plan; visit proxy001.com to check current plans and sign-up terms.
Run Your Next Geo Test With Confidence
If you've been losing hours to false-negative CI failures caused by geo-blocking and datacenter IP reputation filters, the fix doesn't require framework changes or test rewrites — just routing your test traffic through IPs that match your users' actual locations.
Proxy001 makes that practical without enterprise overhead. Their 100M+ residential IP pool spans 200+ regions with real ISP assignments, supports both rotating and sticky sessions for login-heavy flows, and the integration snippets in this article are ready to use with their endpoints today.
Visit proxy001.com to explore plans and sign-up options. Drop your credentials into the playwright.config.ts template above, run the geo-check spec, and confirm the country_code assertion passes. If it does, your geo-blocking flakiness problem is solved.
Top comments (0)