A persistent, logged-in, agent-drivable browser in a container β log in once by hand, then let agents drive it over CDP for as long as the container lives.
scry runs one long-lived headful Chromium inside a container and exposes two surfaces:
- noVNC β a browser-based VNC view for a human to do a one-time interactive login (passwords, MFA, captchas, cookie-consent). You watch the real screen and type.
- Chrome DevTools Protocol (CDP) on
:9222β for an agent / automation to drive the same, already-logged-in browser (navigate, click, scrape, screenshot, run JS).
The login session (cookies, localStorage, IndexedDB β the whole Chromium profile) lives on a mounted volume, not in the image. Disconnecting noVNC stops only the display mirror; Chromium and your session keep running until the container restarts.
Stack: headful Chromium + Xvfb virtual display + x11vnc + noVNC (websockify) + a socat CDP bridge.
Most "browser-in-a-container" images are stateless: every run starts cold, so anything behind a login β and especially anything behind MFA β is painful or impossible to automate. scry flips that:
- A human logs in once via noVNC.
- The profile persists on a volume.
- Agents attach over CDP indefinitely and act as the logged-in user β no re-auth per task, no credentials handed to the agent, no secrets baked into the image.
It is deliberately one browser, one profile, one identity per container. Run N containers for N identities.
one-time, interactive long-lived, automated
ββββββββββββββββββββββββββ ββββββββββββββββββββββββββ
HUMAN ββββ€ noVNC :NOVNC_PORT β AGENT βββββββ€ CDP :CDP_PORT (TCP) β
(browser β websockify β x11vnc β (CDP client) β socat bridge β
tab) β β VNC :VNC_PORT β β (keepalive, no idle β
log in βββββββββββββ¬βββββββββββββ β cut β see hardening) β
once β DISPLAY βββββββββββββ¬βββββββββββββ
β mirrors screen β forwards to
ββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββ β ββββββββββββ
β CONTAINER βΌ βΌ β
β βββββββββββββββββ ββββββββββββββββββββββ β
β β Xvfb (DISPLAY)β β socat β β
β β $SCREEN geom β β :CDP_PORT β β
β ββββββββ¬βββββββββ β β 127.0.0.1: β β
β β renders β CDP_INTERNAL β β
β ββββββββ΄βββββββββββββββββββββββββββ βββββββββββ¬βββββββββββ β
β β Chromium (headful, ONE process, ββββββββββββββββββββ β
β β never tied to VNC lifecycle) β CDP on LOOPBACK only β
β β --remote-debugging-port β (Chromium M113+ refuses to β
β β =CDP_INTERNAL (127.0.0.1) β bind CDP to a public addr) β
β β --user-data-dir=$PROFILE_DIR β β
β ββββββββββββββββ¬ββββββββββββββββββββ β
β β reads / writes profile β
βββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββ
βΌ
ββββββββββββββββββββββββββ
β VOLUME $PROFILE_DIR β β cookies, localStorage, IndexedDB,
β (persistent identity) β the login session. NOT in the image.
ββββββββββββββββββββββββββ
Key points:
- Chromium binds CDP to loopback only (
127.0.0.1:CDP_INTERNAL_PORT).socatis the only thing that re-exposes it on:CDP_PORT, so the bridge β not Chrome β controls who reaches CDP (and is where the WebSocket is hardened, below). - The browser is one persistent process. Its lifecycle is never tied to a VNC session; closing the noVNC tab does not touch Chromium.
- The profile is the only stateful thing, and it lives on the volume.
| Env | Default | Purpose |
|---|---|---|
PROFILE_DIR |
/data |
Chromium --user-data-dir. Mount a volume here β this is the persistent identity. |
DISPLAY |
:99 |
Xvfb display Chromium renders into. |
CDP_PORT |
9222 |
Public-facing CDP port that socat listens on. |
CDP_INTERNAL_PORT |
9223 |
Loopback port Chromium actually binds CDP to. |
NOVNC_PORT |
6080 |
noVNC / websockify HTTP port for the one-time human login. |
VNC_PORT |
5900 |
x11vnc RFB port behind noVNC. Usually not exposed directly. |
SCREEN |
1440x900x24 |
Xvfb geometry WxHxDEPTH (also drives the window size). |
CHROME_EXTRA_FLAGS |
"" |
Extra Chromium flags, appended verbatim (e.g. --lang=en-US --proxy-server=...). |
VNC_PASSWORD |
"" |
Empty β -nopw (open!). Set β -rfbauth via x11vnc -storepasswd. Set this. |
Defaults are a drop-in for the prior single-purpose chrome pod: profile at /data, CDP on 9222 (socat β 9223), noVNC on 6080.
docker build -t scry .
docker run --rm \
--shm-size=1g \
-e VNC_PASSWORD='change-me' \
-p 127.0.0.1:6080:6080 \ # noVNC β login UI (bind to localhost!)
-p 127.0.0.1:9222:9222 \ # CDP β agent control (bind to localhost!)
-v scry-profile:/data \
scryThen:
- Log in once. Open
http://127.0.0.1:6080/, enterVNC_PASSWORD, and use the real Chromium to sign into your target site(s). MFA, captchas, consent banners β all fine, you're a human at a keyboard./auto-connects, scales to fit, and derives the WebSocket scheme from the page (httpsβwss,httpβws) so it works both viaport-forwardand behind a TLS ingress;/vnc.htmlis the full manual UI. - Drive it from an agent. Point any CDP client at
http://127.0.0.1:9222:Then attach Puppeteer / Playwright / chromedp via the WebSocket debugger URL fromcurl -s http://127.0.0.1:9222/json/version # sanity check/json/version.
The session survives container restarts as long as the scry-profile volume is intact (see the cookie-durability caveat below).
--shm-size=1gavoids Chromium crashes from a tiny default/dev/shm. The image also runs with--disable-dev-shm-usageas a belt-and-suspenders fallback.
A StatefulSet gives you a stable identity + a PersistentVolumeClaim for the profile β exactly what a logged-in browser wants. Probe tuning is load-bearing here (see "CDP-stability hardening"): a bare-TCP probe with a low failureThreshold is what killed a real pod mid-session.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: scry
spec:
serviceName: scry
replicas: 1 # one identity per StatefulSet; scale by adding more, not replicas
selector:
matchLabels: { app: scry }
template:
metadata:
labels: { app: scry }
spec:
securityContext:
runAsNonRoot: true
seccompProfile: { type: RuntimeDefault }
containers:
- name: scry
image: ghcr.io/dataplanelabs/scry:latest
ports:
- { name: cdp, containerPort: 9222 }
- { name: novnc, containerPort: 6080 }
env:
- { name: PROFILE_DIR, value: /data }
- name: VNC_PASSWORD
valueFrom:
secretKeyRef: { name: scry-vnc, key: password }
securityContext:
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
volumeMounts:
- { name: profile, mountPath: /data }
- { name: dshm, mountPath: /dev/shm } # back --shm-size with a Memory emptyDir
# --- PROBES: see guidance below. Do NOT shrink these thresholds. ---
startupProbe: # give Chromium + Xvfb + first profile load time to come up
tcpSocket: { port: cdp }
periodSeconds: 10
failureThreshold: 18 # ~180s grace for cold start / first login
livenessProbe: # bare TCP + GENEROUS threshold β a low one evicted a busy pod
tcpSocket: { port: cdp }
periodSeconds: 30
failureThreshold: 6 # ~3 min of sustained misses before a restart
readinessProbe:
tcpSocket: { port: cdp }
periodSeconds: 10
failureThreshold: 3
volumes:
- name: dshm
emptyDir: { medium: Memory, sizeLimit: 1Gi }
volumeClaimTemplates:
- metadata: { name: profile }
spec:
accessModes: [ReadWriteOnce]
resources:
requests: { storage: 2Gi }The pod-killing incident was caused by an aggressive bare-TCP liveness probe with a low threshold: a transient blip restarted the pod and destroyed the in-memory login session. The TCP-on-CDP probe only proves the socat listener is up β it does not prove Chrome is healthy, which is the point: you want it conservative.
startupProbe~180s (periodSeconds: 10 Γ failureThreshold: 18). Chromium + the profile + Xvfb can take a while; don't let liveness fire during cold start.livenessProbebare TCP,failureThreshold >= 6,periodSeconds: 30. A generous threshold tolerates the CDP WebSocket churn this image used to exhibit and the slow answers of a Chrome under load.- Tradeoff vs
httpGet: /json/version. An HTTP probe proves CDP actually responds, but a busy Chrome (mid-navigation, heavy page) can be slow to answer and the probe will then kill a perfectly healthy browser. Prefer bare TCP + generous threshold. If you do usehttpGet, keep the same generous thresholds.
Symptom from a real trace (019e7733): the agent logged "browser connection lost, reconnecting" before every CDP action β the persistent CDP WebSocket was being dropped between calls β and a bare-TCP liveness probe with a low threshold once restarted the pod, destroying the in-memory session. The image hardens against all three:
The persistent browser WebSocket sits idle between agent actions. A naive socat bridge closes idle sockets, which surfaces as constant reconnects. Enable TCP keepalive on both legs and disable the idle close:
socat \
TCP-LISTEN:9222,fork,reuseaddr,keepalive,keepidle=30,keepintvl=10,keepcnt=3 \
TCP:127.0.0.1:9223,keepalivekeepalive,keepidle=30,keepintvl=10,keepcnt=3β start probing after 30s idle, probe every 10s, drop only after 3 missed probes. Keeps a genuinely-idle-but-healthy CDP socket open instead of tearing it down between actions.- Tune
socat -T(idle/inactivity timeout) carefully β a quiet CDP WebSocket is the normal state, not a dead one. Use-T 0(or a value large enough that it never cuts an idle CDP socket).
--disable-dev-shm-usage # avoid /dev/shm OOM crashes in containers
--disable-gpu # no GPU in the container; software render
--disable-background-timer-throttling # don't throttle timers when "backgrounded"
--disable-backgrounding-occluded-windows # the headful window is always occluded β don't pause it
--disable-renderer-backgrounding # keep the renderer at full priority (we drive it via CDP)
The image runs Chromium as a non-root uid (1000) with its renderer sandbox enabled β there is no --no-sandbox. That requires the host/node to allow unprivileged user namespaces (kernel.unprivileged_userns_clone=1); on K8s set the pod securityContext to that uid and pre-own PROFILE_DIR (see Security). Dropping --no-sandbox also removes Chrome's "you are using an unsupported command-line flag" infobar that some sites (e.g. Google) react to.
The three *-background* flags matter specifically because this is a headful but never-foreground browser: Chrome would otherwise treat the window as backgrounded and throttle/suspend it, which looks like instability to the agent.
- Exactly one Chromium process for the container's life. Its lifecycle is independent of any VNC connection β closing noVNC must not kill the browser.
- The entrypoint clears a stale
SingletonLockinPROFILE_DIRon startup (left behind by an unclean shutdown) so Chromium can re-open the existing profile. - The entrypoint ends with
wait $CHROMIUM_PIDβ only a real Chromium exit ends the container, so the container's health tracks the browser, not a wrapper script.
A long-lived Chromium keeps the login partly in memory, so the real durability strategy is avoid restarts (hence the generous probes above).
--user-data-dir=$PROFILE_DIR(mount it on a volume) persists most cookies, localStorage, IndexedDB, and credentials across restarts.- But session-scoped cookies (no expiry, cleared on browser close) live only in memory β an unexpected restart loses them and may force a re-login.
- Mitigation: persist
PROFILE_DIRon a volume and keep probes lenient so Chromium isn't restarted out from under an active session. If a site logs you out after a restart, re-authenticate once via noVNC β the rest of the profile is intact.
CDP is remote code execution. Anyone who can reach :9222 can navigate to file:// URLs, read/write the logged-in session, exfiltrate cookies, run arbitrary JavaScript as the authenticated user, and pivot from there. noVNC is full interactive control of the same browser. Treat both as root-equivalent access to every account this browser is logged into.
- NEVER expose CDP (
:9222) or noVNC (:6080) publicly. No Ingress, no LoadBalancer, no public-p 0.0.0.0:.... Bind to127.0.0.1fordocker run; use aClusterIPService (neverLoadBalancer/NodePort) in K8s and reach the ports viakubectl port-forwardor an in-cluster sidecar. - Run behind a NetworkPolicy. Default-deny ingress to the pod; allow only the specific agent workload(s) that need CDP, and only the human-login path to noVNC. CDP has no authentication of its own β network isolation is its access control.
- Set
VNC_PASSWORD. Unset means the noVNC login screen is open (-nopw). It is the only auth gate in front of interactive control. - Runs non-root with the sandbox ON. The container starts Chromium as uid 1000 and keeps the renderer sandbox (no
--no-sandbox). This needs a node that allows unprivileged user namespaces (kernel.unprivileged_userns_clone=1); verify before deploying. On K8s, setsecurityContext.runAsUser: 1000and chownPROFILE_DIRto it (an init-containerchown -R 1000:1000 /datahandles an existing root-owned volume). If a locked-down node blocks the namespace sandbox the container will crashloop β only then fall back to--no-sandboxviaCHROME_EXTRA_FLAGS, and only inside a confined pod (dropped caps,allowPrivilegeEscalation: false, seccomp, dedicated namespace, the NetworkPolicy above). - No secrets in the image. The login and cookies live only on the runtime
PROFILE_DIRvolume, created when a human logs in via noVNC. The image ships zero credentials β anyone who pulls it gets an empty browser. The identity lives with the volume, so guard the volume (and its backups/snapshots) like the credentials they effectively are.