Deployment - kubed-io/selenium-flow GitHub Wiki

Deployment

Running the server. For pointing a client at one, see Installing.

The server needs a Selenium Grid to drive and does nothing useful without one. Every option below is really a question of where the server runs and how it reaches that Grid.

Configuration

Every setting can come from a YAML config file, the environment, or a flag. See Configuration for every one of them, in all three spellings.


stdio

One process, one client, no network. The client launches the server and talks to it over pipes, which makes it the simplest option and the least shareable โ€” with the default in-memory store, the workspace lasts exactly as long as the process.

TRANSPORT=stdio GRID_URL=http://grid:4444 selenium-flow

There is no token here and none is needed: whoever can start the process is already inside the trust boundary. One process is one client, so its workspace is named stdio and needs no configuration.

The admin UI, the signed file links and MCP Apps all need HTTP, so none of them exist in this mode.

HTTP

The normal way to run it. One process serves any number of clients over Streamable HTTP at /mcp and JSON at /browser/*, both under ROUTE_PREFIX when it is set.

AUTH_TOKEN=โ€ฆ GRID_URL=http://grid:4444 PORT=8000 selenium-flow

Run one replica. MCP transport sessions live in the process's memory, and a client's identity โ€” which decides whether it is given read_resource โ€” is remembered there. The browser is unaffected by a restart: it lives on the Grid and the workspace name leads back to it.

If you want workspaces to survive a restart, set WORKSPACE_STORE=redis and point it at a Redis. The store holds each workspace's record โ€” its context and the Grid id of its open session; the browser is on the Grid regardless. Keys are prefixed selenium-flow:workspace: unless REDIS_PREFIX says otherwise.

Docker

docker compose up --build in a checkout brings up the server and a standalone Grid, with auth off:

  • server on localhost:8000
  • admin UI on localhost:8000
  • the Grid's noVNC view on localhost:7900, where you can watch it work

For your own compose file, the published image is kubed/selenium-flow:

services:
  selenium-flow:
    image: kubed/selenium-flow:latest
    environment:
      GRID_URL: http://selenium:4444
      AUTH_TOKEN: ${AUTH_TOKEN}
      SESSION_PAGE_LOAD_TIMEOUT: "30"
    ports:
      - "8000:8000"
    depends_on:
      - selenium

  selenium:
    image: selenium/standalone-chrome:latest
    shm_size: 2gb
    environment:
      SE_NODE_ENABLE_MANAGED_DOWNLOADS: "true"
    ports:
      - "7900:7900"

SE_NODE_ENABLE_MANAGED_DOWNLOADS is not optional if you want what a site downloads: without it the browser saves into a directory nothing can reach, and workspace://files/downloads never lists it. Screenshots and prints do not need it; they need DATA_DIR. shm_size matters too โ€” Chrome crashes under the default 64MB.

Recording (open_session(record=true)) needs DATA_DIR and RECORDING_ENABLED=true here, and the node's /videos delivered into RECORDING_DIR (default $DATA_DIR/recordings) โ€” a shared volume, or rclone to the same storage. Keep SE_NODE_MAX_SESSIONS=1 on recording nodes. In docker-compose.yaml it is opt-in: run mkdir -p data/recordings && chmod -R 777 data once (Docker creates a missing bind-mount folder as root, and the image runs as 65534), then uncomment the recording lines. The README's Recording section has the delivery options.

Kubernetes

The reference deployment lives in the cluster repo under apps/selenium, as a Kustomize app with three components:

Component Holds
components/helm the Selenium Grid chart โ€” hub and Chrome nodes
components/mcp this server: Deployment, Service, ConfigMap, and its token
components/ingress one host for both halves, plus the certificate

Five details there are worth copying rather than rediscovering.

The config file is a ConfigMap. components/mcp renders config.yaml with a configMapGenerator and mounts it at /etc/selenium-flow/config.yaml, with CONFIG_FILE set to that path in the container's env. See Configuration for what goes in it.

The token is generated, never authored. An External Secrets Password generator mints it and an ExternalSecret materialises it into a Secret the Deployment reads with envFrom. No value exists in git or in a vault first.

Declare labels on the generated Secret. kubectl apply --applyset stamps its label onto everything it manages; a generated Secret inheriting the ExternalSecret's labels ends up looking like a member of the applyset, and the next apply prunes it โ€” rotating the token out from under every connected client, silently, because the pods keep the old value until they restart.

envFrom does not reload. Rotating the Secret needs a kubectl rollout restart; the running pod holds the value it started with.

Mount an emptyDir at /tmp if the container runs with a read-only root filesystem. Uploads stage there, and without it they fail with "No usable temporary directory".

enableServiceLinks: false on the pod. Kubernetes otherwise injects a <SERVICE>_PORT variable for every Service in the namespace; the config's env source is lenient about these and drops what it does not know, and this is a second guard against one arriving at all.

One host, two halves

The ingress puts the Grid console and this server on the same origin โ€” the Grid at / and the server under /flow with a Traefik stripPrefix:

annotations:
  traefik.ingress.kubernetes.io/router.middlewares: flow-selenium-flow-strip@kubernetescrd
spec:
  rules:
  - host: selenium.example.com
    http:
      paths:
      - path: /flow
        pathType: Prefix
        backend: { service: { name: selenium-flow, port: { name: http } } }
      - path: /
        pathType: Prefix
        backend: { service: { name: selenium-grid-selenium-hub, port: { number: 4444 } } }

Same-origin is the point: the admin UI frames the Grid console as a tab, and the console's live browser view needs no cross-origin exception. Longest path wins in Traefik, so /flow is matched before the Grid's catch-all, and StripPrefix passes a path that does not carry the prefix through untouched.

There are now two ways to do this, and the second is the tidier one:

  • Strip at the ingress, as above. The server runs at the root, knows nothing about being mounted, and PUBLIC_BASE_URL has to be told where it really lives: https://selenium.example.com/flow.
  • Tell the server instead: ROUTE_PREFIX=/flow, and drop StripPrefix. The served paths and the public URL then agree, which is what makes a link the server hands out work without a second setting to keep in sync.

Either way the admin UI is fine: it derives everything from its own path, so it does not need to be told. /health, /started, /ready and /info answer at the root as well as under the prefix, so a probe never depends on that choice.

Behind agentgateway

An agentgateway in front of /mcp can register this server two ways. Set mcp.provider on neither: for Keycloak it makes the gateway rewrite the issuer, and RFC 9207 clients then reject it (agentgateway#3668). Send the workspace name as the X-Workspace header; the gateway drops the query string, so ?workspace= never arrives.

The gateway holds the token. It validates the caller's Keycloak JWT and sends the server's own token to the backend as Authorization: Bearer <value>. The server sees the admin and needs no oidc settings:

kind: AgentgatewayPolicy
spec:
  backend:
    auth:
      secretRef:
        name: selenium-flow-token
        key: AUTH_TOKEN

The server verifies. With oidc.issuer, oidc.audience, oidc.jwks_uri and oidc.roles set (see Configuration), the gateway hands the caller's JWT on and the server checks it and sees who is calling:

kind: AgentgatewayPolicy
spec:
  backend:
    auth:
      passthrough: {}

The admin UI signs in with the issuer. Add a client for it in the realm: selenium-flow-admin, Client authentication off (public), Standard flow only, PKCE S256, Valid redirect URIs the UI's own URL (https://selenium.example.com/flow/), Web origins +, and the client scope that puts the MCP audience and the roles claim in a token as a default scope. Then set oidc.client_id: selenium-flow-admin and oidc.admin_roles: [admin]. The page shows Sign in with OIDC under the token field; a person without an admin role is told so. Do not set oidc.audience to the client id: the admin door would then accept an ID token.

The REST routes and signed links stay token-only; the gateway routes /mcp alone.

A session's lifetime is the Grid's job

Nothing here runs a cleanup loop, and nothing should. The Grid expires idle browsers on SE_NODE_SESSION_TIMEOUT and the workspace store expires its own keys on WORKSPACE_TTL. If the Grid reaped a browser this server remembered, the next call notices and reopens it at the page it was last on, with the same settings.

โš ๏ธ **GitHub.com Fallback** โš ๏ธ