Skip to main content
SeleniumDecoded

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.

Selenium 4 Medium Updated 9 Sept 2026 · Verified against Selenium 4.48.0

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

ComponentRoleScales how
RouterEntry point; forwards new sessions to the queue and commands to NodesHorizontally behind an ingress
Session QueueHolds pending session requestsRedis-backed since 4.45
DistributorMatches requests to Node slotsRedis-backed since 4.44 for multiple instances
Session MapRecords which Node owns which sessionRedis-backed since 4.45
Event BusZeroMQ pub/sub between components; also carries session eventsOne per Grid (or bundled in Hub)
NodesOwn browser slots; run browsers directly or provision containers/Pods per sessionAutoscaled
Video sidecarRecords a Node’s display to MP4One 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.

config.toml
[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 = 8
docker-compose.yml
services:
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:

Capabilities for Dynamic Grid
Selenium 4 Stable
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 name
RemoteWebDriver 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.toml
apiVersion: v1
kind: ConfigMap
metadata: { 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 namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { 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.

Terminal window
helm repo add docker-selenium https://www.selenium.dev/docker-selenium
helm 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.internal

Key values to know:

ValueEffect
isolateComponents=trueRun Router, Distributor, Session Map and Queue as separate Deployments (production)
autoscaling.scalingTypejob (one Pod per session, KEDA ScaledJob) or deployment (scale replicas)
autoscaling.scaledOptions.maxReplicaCountGlobal cap on browser Pods
redis.enabled=trueRedis-backed Session Map, Queue and Distributor for HA
videoRecorder.enabledAttach the video sidecar to Node Pods
ingress.classNameTraefik by default from 4.41
tracing.enabledOpenTelemetry 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.

Signal a failed test to the Grid
Selenium 4 Stable
// 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 LocalFileDetector on the RemoteWebDriver so sendKeys on a file input transfers the file to the browser Pod.
  • Downloads: enable managed downloads on Nodes (SE_NODE_ENABLE_MANAGED_DOWNLOADS=true) and request se:downloadsEnabled in capabilities; then list and fetch files through GET /session/{id}/se/files and POST /session/{id}/se/files with {"name": "..."}. Selenium 4.48 extended this to Kubernetes, Docker and relay sessions.
Fetch a downloaded file from a Grid session
Selenium 4 Medium
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 = True
driver = 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 /ui shows Nodes, slots, queue and live VNC.
  • Status endpoint /status for health checks; "ready": true means sessions can be created.
  • Structured logs: SE_STRUCTURED_LOGS=true for JSON, SE_PLAIN_LOGS=true to force plain output, feeding Loki or Elasticsearch.
  • Tracing: OpenTelemetry is built in; SE_ENABLE_TRACING=true plus 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/shm as 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 LocalFileDetector handle files.
  • Turn on tracing and structured logs before you need them; sizing is about CPU, RAM and /dev/shm per browser.

Related lessons