Grid on Docker and Kubernetes
Deploy Selenium Grid 4.4x for real workloads: Dynamic Grid with Docker, the native Kubernetes session factory, the Helm chart with autoscaling, Redis-backed state, session events, video, and observability.
A standalone container is enough for a laptop or a small CI job. Once a team wants hundreds of parallel browsers, multiple browser versions, video of failures and no idle machines, Grid needs to run on a container platform. Grid 4.41 (February 2026) turned that from “possible with effort” into a supported configuration: browser Pods provisioned natively on Kubernetes, session events from tests, event-driven video, Redis-backed state for high availability, and a Helm chart with Traefik ingress and KEDA autoscaling.
The Building Blocks
| Component | Role | Scales how |
|---|---|---|
| Router | Entry point; forwards new sessions to the queue and commands to Nodes | Horizontally behind an ingress |
| Session Queue | Holds pending session requests | Redis-backed since 4.45 |
| Distributor | Matches requests to Node slots | Redis-backed since 4.44 for multiple instances |
| Session Map | Records which Node owns which session | Redis-backed since 4.45 |
| Event Bus | ZeroMQ pub/sub between components; also carries session events | One per Grid (or bundled in Hub) |
| Nodes | Own browser slots; run browsers directly or provision containers/Pods per session | Autoscaled |
| Video sidecar | Records a Node’s display to MP4 | One per Node |
For small deployments the Hub image bundles Router, Distributor, Session Map, Session Queue and Event Bus. For large ones you run them separately with Redis.
Dynamic Grid on Docker
The node-docker image starts one fresh browser container per session using a config file that maps stereotypes to images. Every session gets a clean browser and, optionally, a video.
[docker]configs = [ "selenium/standalone-chrome:4.48.0", '{"browserName": "chrome"}', "selenium/standalone-chrome:133.0", '{"browserName": "chrome", "browserVersion": "133"}', "selenium/standalone-firefox:4.48.0", '{"browserName": "firefox"}',]host-config-keys = ["Dns", "DnsOptions", "DnsSearch", "ExtraHosts", "Binds"]url = "http://127.0.0.1:2375"video-image = "selenium/video:ffmpeg-7.1-20260827"
[node]max-sessions = 8services: hub: image: selenium/hub:4.48.0 ports: ["4444:4444"] node-docker: image: selenium/node-docker:4.48.0 volumes: - ./config.toml:/opt/selenium/config.toml - ./assets:/opt/selenium/assets # videos and downloads land here per session id - /var/run/docker.sock:/var/run/docker.sock environment: SE_EVENT_BUS_HOST: hub SE_EVENT_BUS_PUBLISH_PORT: 4442 SE_EVENT_BUS_SUBSCRIBE_PORT: 4443 SE_UPLOAD_FAILURE_SESSION_ONLY: "true"Request video and a specific version with se: capabilities:
ChromeOptions options = new ChromeOptions();options.setBrowserVersion("133");options.setCapability("se:recordVideo", true);options.setCapability("se:screenResolution", "1366x768");options.setCapability("se:name", "checkout-applies-coupon"); // shows in the Grid UI and video nameRemoteWebDriver driver = new RemoteWebDriver(new URL("http://grid.internal:4444"), options);options = webdriver.ChromeOptions()options.browser_version = "133"options.set_capability("se:recordVideo", True)options.set_capability("se:screenResolution", "1366x768")options.set_capability("se:name", "checkout-applies-coupon")driver = webdriver.Remote("http://grid.internal:4444", options=options)const options = new chrome.Options().setBrowserVersion('133').set('se:recordVideo', true).set('se:screenResolution', '1366x768').set('se:name', 'checkout-applies-coupon');const driver = await new Builder().usingServer('http://grid.internal:4444').forBrowser('chrome').setChromeOptions(options).build();var options = new ChromeOptions { BrowserVersion = "133" };options.AddAdditionalOption("se:recordVideo", true);options.AddAdditionalOption("se:screenResolution", "1366x768");options.AddAdditionalOption("se:name", "checkout-applies-coupon");var driver = new RemoteWebDriver(new Uri("http://grid.internal:4444"), options);Native Kubernetes Dynamic Grid (4.41+)
Before 4.41, running Dynamic Grid on Kubernetes meant a Docker daemon sidecar per Node. The KubernetesSessionFactory removes that: the Node calls the Kubernetes API to create a browser Pod per session, inheriting the Node Pod’s tolerations, affinity and resource limits through InheritedPodSpec. Reference manifests live in the docker-selenium repository under kubernetes/DynamicGrid/.
The essentials:
# ConfigMap: stereotypes map to images, same idea as config.tomlapiVersion: v1kind: ConfigMapmetadata: { name: selenium-node-kubernetes-config }data: config.toml: | [node] max-sessions = 10 session-factory-provider = "org.openqa.selenium.grid.node.k8s.KubernetesSessionFactoryProvider" [kubernetes] namespace = "selenium" inherit-pod-spec = true configs = [ "selenium/standalone-chrome:4.48.0", '{"browserName": "chrome"}', "selenium/standalone-firefox:4.48.0", '{"browserName": "firefox"}', ] video-image = "selenium/video:ffmpeg-7.1-20260827"---# RBAC: the Node needs to create and delete Pods in its namespaceapiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata: { name: selenium-node, namespace: selenium }rules: - apiGroups: [""] resources: ["pods", "pods/log"] verbs: ["get", "list", "watch", "create", "delete"]Environment variables such as SE_NODE_GRID_URL, SE_NODE_MAX_SESSIONS and the video settings are identical between Docker and Kubernetes backends since 4.41, so a config tested with Compose ports to a cluster without changes.
The Helm Chart
For most teams the Helm chart is the right entry point. It deploys Hub or distributed components, Nodes per browser, optional Redis, Traefik ingress (replacing NGINX ingress from 4.41), and KEDA-based autoscaling that scales Node deployments from the session queue length.
helm repo add docker-selenium https://www.selenium.dev/docker-seleniumhelm install selenium-grid docker-selenium/selenium-grid \ --namespace selenium --create-namespace \ --set isolateComponents=true \ --set autoscaling.enabled=true \ --set autoscaling.scalingType=deployment \ --set chromeNode.maxReplicaCount=50 \ --set firefoxNode.maxReplicaCount=20 \ --set videoRecorder.enabled=true \ --set ingress.hostname=grid.internalKey values to know:
| Value | Effect |
|---|---|
isolateComponents=true | Run Router, Distributor, Session Map and Queue as separate Deployments (production) |
autoscaling.scalingType | job (one Pod per session, KEDA ScaledJob) or deployment (scale replicas) |
autoscaling.scaledOptions.maxReplicaCount | Global cap on browser Pods |
redis.enabled=true | Redis-backed Session Map, Queue and Distributor for HA |
videoRecorder.enabled | Attach the video sidecar to Node Pods |
ingress.className | Traefik by default from 4.41 |
tracing.enabled | OpenTelemetry export to Jaeger or an OTLP collector |
Point tests at the ingress hostname; RemoteWebDriver code does not change.
Session Events and Failure-Only Video (4.41+)
Tests can post custom events to the Grid’s event bus through the driver. The video sidecar listens and, with SE_UPLOAD_FAILURE_SESSION_ONLY=true, keeps only recordings for sessions that reported a failure. This turns video from a storage problem into a debugging tool.
// In the test framework's failure hook((RemoteWebDriver) driver).fireSessionEvent("test:failed", Map.of( "testName", context.getDisplayName(), "reason", cause.getMessage()));driver.fire_session_event("test:failed", {"testName": request.node.name, "reason": str(exc)})await driver.fireSessionEvent('test:failed', { testName: this.currentTest.title, reason: err.message });((RemoteWebDriver)driver).FireSessionEvent("test:failed", new Dictionary<string, object>{ ["testName"] = TestContext.CurrentContext.Test.Name, ["reason"] = TestContext.CurrentContext.Result.Message ?? "",});Videos are stored per session id under the assets volume (or uploaded to S3-compatible storage with SE_VIDEO_UPLOAD_ENABLED=true and the rclone configuration in the chart). Include the session id in your test report so the video is one click away.
Files: Uploads and Downloads Through the Grid
- Uploads: set
LocalFileDetectoron theRemoteWebDriversosendKeyson a file input transfers the file to the browser Pod. - Downloads: enable managed downloads on Nodes (
SE_NODE_ENABLE_MANAGED_DOWNLOADS=true) and requestse:downloadsEnabledin capabilities; then list and fetch files throughGET /session/{id}/se/filesandPOST /session/{id}/se/fileswith{"name": "..."}. Selenium 4.48 extended this to Kubernetes, Docker and relay sessions.
ChromeOptions options = new ChromeOptions();options.setCapability("se:downloadsEnabled", true);RemoteWebDriver driver = new RemoteWebDriver(gridUrl, options);
driver.get("https://app.example.com/reports");driver.findElement(By.id("export-csv")).click();
new WebDriverWait(driver, Duration.ofSeconds(30)) .until(d -> ((HasDownloads) d).getDownloadableFiles().contains("report.csv"));((HasDownloads) driver).downloadFile("report.csv", Path.of("target/downloads"));options = webdriver.ChromeOptions()options.enable_downloads = Truedriver = webdriver.Remote(grid_url, options=options)
driver.get("https://app.example.com/reports")driver.find_element(By.ID, "export-csv").click()
WebDriverWait(driver, 30).until(lambda d: "report.csv" in d.get_downloadable_files())driver.download_file("report.csv", "downloads")const options = new chrome.Options().enableDownloads();const driver = await new Builder().usingServer(gridUrl).forBrowser('chrome').setChromeOptions(options).build();
await driver.get('https://app.example.com/reports');await driver.findElement(By.id('export-csv')).click();
await driver.wait(async () => (await driver.getDownloadableFiles()).includes('report.csv'), 30000);await driver.downloadFile('report.csv', 'downloads');var options = new ChromeOptions { EnableDownloads = true };var driver = new RemoteWebDriver(gridUri, options);
driver.Navigate().GoToUrl("https://app.example.com/reports");driver.FindElement(By.Id("export-csv")).Click();
new WebDriverWait(driver, TimeSpan.FromSeconds(30)).Until(d => driver.GetDownloadableFiles().Contains("report.csv"));driver.DownloadFile("report.csv", "downloads");Observability
- Grid UI at
/uishows Nodes, slots, queue and live VNC. - Status endpoint
/statusfor health checks;"ready": truemeans sessions can be created. - Structured logs:
SE_STRUCTURED_LOGS=truefor JSON,SE_PLAIN_LOGS=trueto force plain output, feeding Loki or Elasticsearch. - Tracing: OpenTelemetry is built in;
SE_ENABLE_TRACING=trueplus an OTLP endpoint shows every command as a span, which is how you find a slow Node or a slow app. - Metrics: KEDA exposes queue length; export Grid JVM metrics with the Prometheus JMX exporter if you need dashboards.
Sizing Rules of Thumb
- One Chrome session needs roughly 1 CPU and 1 to 2 GB of RAM during page loads; size Node resource requests accordingly and cap
max-sessions. - Mount
/dev/shmas a memory-backed volume of at least 2 GB per browser Pod (the chart does this). - Put the Grid in the same region as the application under test and as the test runners; round-trip latency multiplies by thousands of commands.
- Keep session timeouts short (
SE_NODE_SESSION_TIMEOUT=300) so abandoned sessions free slots.
Summary
- Dynamic Grid gives every session a fresh browser container or Pod; since 4.41 Kubernetes needs no Docker daemon.
- The Helm chart deploys isolated components, Redis-backed state, Traefik ingress, KEDA autoscaling and video in one command.
- Session events let tests keep only failed-test videos; managed downloads and
LocalFileDetectorhandle files. - Turn on tracing and structured logs before you need them; sizing is about CPU, RAM and
/dev/shmper browser.