How to Run a Selenium Grid in a Cloud Sandbox
Updated October 27, 2026 .md
Short answer: Start the official Selenium hub, Chrome and Firefox node images with Docker Compose in a default Sandboxer sandbox, host port 4444 with sbx host, and pass the URL's token to webdriver.Remote as a cookie header. Our remote tests passed on both browsers; each WebDriver command took about 136 ms from outside and 7 ms inside.
What we ran#
Everything below ran on production on October 27, 2026, in one default sandbox (4 vCPU, 8 GB, $0.036/hour) created with --no-env. The Grid images are selenium/hub, selenium/node-chrome and selenium/node-firefox at tag 4.51.0-20261010, from the docker-selenium project. The test client is Python Selenium 4.51.0 on our own machine. Timings are single runs or short series, so treat them as rough.
| Step | Result |
|---|---|
docker compose up -d (pulls 3 images) |
25.7 s |
| Grid status | Ready, Chrome 155.0 node (2 slots) and Firefox 158.0 node (1 slot) |
| Hosted Grid URL without the token | 401 |
| Remote test from outside, Chrome | New session 0.54 to 0.63 s, test 1.15 to 1.41 s (10 runs) |
| Remote test from outside, Firefox | New session 1.85 to 2.18 s, test 1.30 to 1.53 s (3 runs) |
| One WebDriver command, client outside | 135.9 ms median |
| One WebDriver command, client inside the sandbox | 6.8 ms median |
| 4 Chrome tests at once, 2 slots | All passed; the extra sessions waited in the Grid's queue, 7.9 s wall time |
| Memory used with the Grid idle | About 1.5 GB of 8 GB |
sbx stop --wait then sbx resume |
Memory restored, same containers, tests passed on the same URL |
| Boot from disk | Containers back within 5 s; the first request came before the hub was listening, a retry worked |
1. Start the Grid with Docker Compose#
Docker and Compose are already installed in every sandbox. This file follows the hub-and-nodes layout from the docker-selenium README, with three changes: pinned image tags, restart: unless-stopped, and two Chrome sessions per node.
# compose.yaml
services:
selenium-hub:
image: selenium/hub:4.51.0-20261010
restart: unless-stopped
ports:
- "4444:4444"
chrome:
image: selenium/node-chrome:4.51.0-20261010
restart: unless-stopped
shm_size: 2gb
depends_on: [selenium-hub]
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
- SE_NODE_MAX_SESSIONS=2
- SE_NODE_OVERRIDE_MAX_SESSIONS=true
ports:
- "7900:7900"
firefox:
image: selenium/node-firefox:4.51.0-20261010
restart: unless-stopped
shm_size: 2gb
depends_on: [selenium-hub]
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443sbx new --name selenium-grid --type default --no-env
id=sb_hwfyk2qg7a # the id that sbx new printed
sbx exec $id "mkdir -p grid"
sbx scp compose.yaml $id:/home/user/grid/compose.yaml
sbx exec $id --timeout 600 "cd grid && docker compose up -d" Container grid-selenium-hub-1 Started
Container grid-firefox-1 Starting
Container grid-chrome-1 Starting
Container grid-firefox-1 Started
Container grid-chrome-1 Startedsbx exec stops waiting after 30 seconds by default, so give the first up a longer --timeout while it pulls the images. Check the Grid with its /status endpoint. We used a small script that prints one line per node:
# status.py
import json, urllib.request
v = json.load(urllib.request.urlopen("http://localhost:4444/status"))["value"]
print("ready:", v["ready"], "|", v["message"])
for n in v["nodes"]:
browsers = sorted({s["stereotype"]["browserName"] + " " + s["stereotype"].get("browserVersion", "") for s in n["slots"]})
print(n["version"], n["availability"], "maxSessions", n["maxSessions"], browsers)sbx scp status.py $id:/home/user/grid/status.py
sbx exec $id "python3 grid/status.py"ready: True | Selenium Grid ready.
4.51.0 (revision 35c5fab …) UP maxSessions 1 ['firefox 158.0']
4.51.0 (revision 35c5fab …) UP maxSessions 2 ['chrome 155.0']Three settings in the file matter:
shm_size: 2gb. The docker-selenium README asks for 2 GB of shared memory per browser container; Docker's default is 64 MB.SE_NODE_MAX_SESSIONSwithSE_NODE_OVERRIDE_MAX_SESSIONS=true. Each node runs one session by default. The README advises at most one session per CPU, so on a 4 vCPU sandbox keep the total across nodes at 4 or fewer.restart: unless-stopped. When the sandbox boots from disk instead of restoring memory, Docker starts the Grid again (step 5).
2. Give the Grid a private HTTPS URL#
sbx host $id 4444 --title "Selenium Grid"https://apt-scarlet-ginger-4444.box.sandboxer.com?_token=<token>The URL is private. Anyone who runs tests through it can start browsers in your sandbox, so keep the token out of logs and CI output.
curl -s -o /dev/null -w "%{http_code}\n" https://apt-scarlet-ginger-4444.box.sandboxer.com/status401Opening the full URL in a browser answers 302 and sets the __sbx_auth cookie, so the Grid UI at /ui/ loads normally after that (200 in our test). How private URLs work is covered in Give a sandbox port an HTTPS URL.
3. Run a remote WebDriver test against it#
A WebDriver client sends many requests to paths under the Grid address (/session, /session/<id>/url and so on). So give webdriver.Remote the bare address and send the token as the __sbx_auth cookie on every request. Selenium's ClientConfig takes extra headers for this (Selenium HTTP client docs).
# remote_test.py: run one WebDriver test on the Grid in the sandbox
import os, sys, time
from urllib.parse import urlsplit, parse_qs
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.client_config import ClientConfig
hosted = os.environ["GRID_URL"] # the private URL from `sbx host`, with ?_token=…
parts = urlsplit(hosted)
token = parse_qs(parts.query)["_token"][0]
grid = f"{parts.scheme}://{parts.netloc}"
browser = sys.argv[1] if len(sys.argv) > 1 else "chrome"
options = webdriver.ChromeOptions() if browser == "chrome" else webdriver.FirefoxOptions()
config = ClientConfig(remote_server_addr=grid,
extra_headers={"Cookie": f"__sbx_auth={token}"})
t0 = time.perf_counter()
driver = webdriver.Remote(command_executor=grid, options=options, client_config=config)
t1 = time.perf_counter()
try:
driver.get("https://www.selenium.dev/selenium/web/web-form.html")
driver.find_element(By.NAME, "my-text").send_keys("hello from the grid")
driver.find_element(By.CSS_SELECTOR, "button").click()
message = driver.find_element(By.ID, "message").text
t2 = time.perf_counter()
caps = driver.capabilities
print(f"{caps['browserName']} {caps['browserVersion']} | {driver.title} | {message}")
print(f"new session {t1 - t0:.2f}s, fill + submit {t2 - t1:.2f}s")
finally:
driver.quit()The page under test is the Selenium project's own demo form. Run it from your machine:
pip install selenium # 4.51.0 in our run
export GRID_URL='https://apt-scarlet-ginger-4444.box.sandboxer.com?_token=<token>'
python remote_test.py chrome
python remote_test.py firefoxchrome 155.0.8059.79 | Web form - target page | Received!
new session 0.62s, fill + submit 1.41s
firefox 158.0 | Web form - target page | Received!
new session 1.85s, fill + submit 1.46sThe Grid picks the node from the browser in your options, so the same URL serves both browsers.
4. Where to run the test runner#
Every WebDriver call is an HTTP round trip to the Grid. We timed 20 calls to driver.title in one session, first from our machine through the hosted URL, then from inside the sandbox against http://localhost:4444:
apt-scarlet-ginger-4444: median 135.9 ms per command
localhost:4444: median 6.8 ms per commandThe gap is network distance, about 20 times per command in our test. Your number depends on where you are; the sandbox ran in eu-central. A test with 200 WebDriver calls spends about 27 seconds waiting on the network from outside, and about 1.4 seconds inside. Two ways to use this:
| Situation | Run the tests from | Grid URL |
|---|---|---|
| Debugging one test from your IDE | Your machine | The private hosted URL, cookie header as above |
| A full suite, or tests in CI | Inside the sandbox: sbx scp the tests in, sbx exec them |
http://localhost:4444, no token |
| Many suites at once | One sandbox per suite (see Run coding agents in parallel) | localhost in each |
Inside the sandbox, Python, uv and Docker are already there. uv venv .venv && uv pip install --python .venv/bin/python selenium took a few seconds in our run.
More sessions than slots#
We started 4 Chrome tests at once against the 2 Chrome slots. All 4 passed. The Grid queued the extra session requests and started them as slots freed up, so new-session times ranged from 0.69 s to 6.32 s, and the batch took 7.9 s. The README describes the queue timeout settings (SE_SESSION_REQUEST_TIMEOUT) if your suites queue for longer.
5. Watch a test through noVNC#
Each node image serves a noVNC page on port 7900. The compose file publishes the Chrome node's; host it like the Grid:
sbx host $id 7900 --title "Chrome node VNC"Without the token it returned 401. With it, the noVNC page loaded, and the WebSocket handshake to /websockify returned 101 with the cookie. The VNC password is secret unless you set SE_VNC_PASSWORD (docker-selenium VNC docs). The hosted URL's token stays the first lock; the VNC password is a second one.
This view shows the browsers inside the node container. It is separate from the sandbox desktop (sbx desktop), which Run Browser Use or Playwright with real Chrome uses with the Chrome installed on the VM itself.
6. Stop, resume and cold boot#
Stop and resume with memory. sbx stop $id --wait took 2.2 s and sbx resume $id 1.3 s. The sandbox showed memoryRestored: true. The three containers had the same uptime as before the stop, the Grid was ready, and the remote Chrome and Firefox tests passed again on the same URL and token. A stopped sandbox costs nothing for compute.
Boot from disk. Memory is restored only on a machine with the same CPU type; otherwise the sandbox boots from disk. We forced that by resuming on another size (sbx resume $id --type large, which reported memoryRestored: false, resumeReason: "resized"):
grid-chrome-1 Up 5 seconds
grid-firefox-1 Up 5 seconds
grid-selenium-hub-1 Up 5 seconds
ready: True | Selenium Grid ready.Docker started all three containers again because of restart: unless-stopped. The hosted route and token were kept. Our first remote test right after the boot got the proxy's "Nothing is answering on port 4444" page, because the hub wasn't listening yet; a retry a few seconds later passed. In CI, poll /status until ready is true before you start the suite. Moving to a smaller size isn't supported yet, so size the sandbox for the Grid you want from the start.
FAQ#
Can I use selenium/standalone-chrome instead of a hub and nodes?#
Yes. The README's quick start runs one standalone container on port 4444 with --shm-size="2g". Host port 4444 the same way and the client code doesn't change. We tested the hub-and-nodes layout because it runs Chrome and Firefox behind one URL.
Why not put the ?_token= URL straight into command_executor?#
The client adds paths such as /session to the address you give it, and the token belongs in the query string, not in the path. Pass the bare address and send the token as the __sbx_auth cookie, as in step 3. For a public Grid (sbx host $id 4444 --public) no cookie is needed, but then anyone with the URL can start browsers in your sandbox.
How many parallel browsers fit in a default sandbox?#
The docker-selenium README recommends at most one browser session per CPU, which is 4 on a default sandbox. Our idle Grid used about 1.5 GB of the 8 GB. If a browser runs out of memory, its process exits with code 137; see Fix exit code 137 or a full disk.
Did any request fail?#
Two did in about 20 remote runs on October 27, 2026. One came right after a cold boot, before the hub was listening (step 6). The other was a rare 502 during a platform update; retry after a few seconds. The retry passed and the next 15 runs all passed. Poll /status before a suite starts.
Related#
More guides
For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.
For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.
The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.
If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.