Deployment - kubed-io/selenium-flow GitHub Wiki
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.
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.
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-flowThere 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.
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-flowRun 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 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.
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.
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_URLhas to be told where it really lives:https://selenium.example.com/flow. -
Tell the server instead:
ROUTE_PREFIX=/flow, and dropStripPrefix. 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.
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_TOKENThe 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.
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.