BrowserStack Local Testing: Running One Tunnel for a Whole CI Fleet
BrowserStack Local Testing tunnels let cloud browsers reach localhost and staging hosts. Here's how to run one shared tunnel per CI run, avoid the readiness race, and scope the exposure.
29 Jun 2026, 20:09 UTC

Your staging app sits behind the office firewall, but the browsers you want to test against live in BrowserStack's cloud. The first time you wire this up, the failure is almost always the same: the cloud browser says connection refused, because localhost on a remote device means that device, not your machine. The fix is BrowserStack Local Testing — an encrypted tunnel from your CI runner to BrowserStack's cloud — and the real engineering question isn't whether to use it, but how to run it so twenty parallel jobs don't each spawn their own tunnel process.
What the tunnel actually does
Local Testing runs a small binary (BrowserStackLocal) on your machine or CI runner. It opens an outbound encrypted connection to BrowserStack, so no inbound firewall changes are needed. When a cloud browser or real device requests a URL, traffic for your internal hosts flows back through that connection and is fetched from your network's point of view. That means http://localhost:3000, internal DNS names like staging.internal.example.com, and VPN-only hosts all become reachable from the cloud session.
A test opts in through a capability. In current Selenium 4 / W3C-style configuration it lives inside bstack:options:
{
"bstack:options": {
"local": "true",
"localIdentifier": "ci-build-1042"
}
}Older examples use a top-level browserstack.local: true capability instead. Both appear in blog posts and Stack Overflow answers, so check which form your client library version expects — this naming has shifted over the years and copying a stale snippet is a common source of "tunnel ignored" confusion.
One tunnel, many workers
The naive CI setup starts a tunnel per test worker. It works, but you end up managing N processes, N startup delays, and N chances for an orphaned binary when a job gets killed. The better pattern is one shared tunnel per pipeline run, keyed by a localIdentifier:
# Run on the CI runner, before tests start, as a background process.
# Requires your BrowserStack access key (store it in CI secrets).
./BrowserStackLocal --key $BROWSERSTACK_ACCESS_KEY \
--local-identifier ci-build-1042 \
--only staging.internal.example.com,localhostEvery parallel worker then sets the same localIdentifier in its capabilities and shares that single tunnel. Two flags here pull real weight:
--local-identifierscopes the tunnel so it doesn't collide with other builds running at the same time on your account. Use something unique per run, like the pipeline ID.--onlyrestricts which hosts route through the tunnel. This is your blast-radius control: the tunnel exposes part of your internal network to cloud sessions, so list only the hosts your tests actually need. Never point it at production.
Other flags worth knowing: --force-local resolves a hostname from the runner's network instead of public DNS (useful when a name resolves differently inside your VPN), and --proxy-host/--proxy-port let the tunnel itself traverse a corporate proxy. Flag names are version-sensitive — run ./BrowserStackLocal --help on the binary you actually downloaded rather than trusting any article, this one included.
The readiness race that bites everyone
The most common CI failure mode isn't a broken tunnel; it's tests starting before the tunnel finishes connecting. The symptom is a wave of connection-refused or timeout errors at the start of the run that mysteriously disappears if you rerun. The binary prints a success line to its log once connected — gate your test phase on that. A minimal shell check:
./BrowserStackLocal --key $BROWSERSTACK_ACCESS_KEY \
--local-identifier ci-build-1042 --daemon start
# Poll until the tunnel reports ready (adjust to the binary's log/exit behavior)
for i in $(seq 1 30); do
if ./BrowserStackLocal --key $BROWSERSTACK_ACCESS_KEY --daemon status | grep -qi running; then
break
fi
sleep 2
doneAlternatively, the official language bindings (Java, Node, Python, Ruby, C#) start and stop the tunnel from your test framework's setup/teardown hooks and handle readiness internally — a good fit if a single test process owns the run, less good when many workers share one tunnel.
Cleanup matters as much as startup. A CI job killed mid-run can leave the binary alive, and orphaned tunnels linger. Stop it in a teardown step that runs even on failure (--daemon stop with the same key), and prefer per-run identifiers so a leftover tunnel never silently serves the wrong build.
Trade-offs worth pricing in
Every request through the tunnel pays a latency toll — your cloud browser in another region is fetching pages via your runner's uplink. Large suites slow down, and timing-sensitive tests (animations, debounce logic, race conditions) can behave differently than they do against a public URL. If a test fails only through the tunnel, check Automate's session logs and recorded video before blaming the app; the text and network logs usually make it obvious whether the page never loaded (tunnel problem) or loaded and misbehaved (app problem).
Local Testing is included with Live and Automate rather than sold separately, but plan limits change — confirm what your tier covers before designing around heavy parallel tunnel use.
Verify it end to end
Before trusting the setup in CI, prove it locally: serve a throwaway page with python -m http.server 8000, start the tunnel, and run one Automate session asserting on unique text in that page. Then check two places: the tunnel indicator in the BrowserStack dashboard, and the session's network logs showing the request succeeded. If both confirm, your capability syntax, identifier, and firewall path are all correct — and the CI version is just plumbing.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.