Playwright e2e inside the Docker sandbox (credential-free, zero runtime egress)
If you develop in a network-isolated Docker sandbox (egress firewall, no host browser, no
Cloudflare login — the claude-code-docker-sandbox
setup), a Playwright suite can still run in-container with zero runtime egress. The
enabling idea is that sandbox skill's core rule: the firewall is the runtime
entrypoint, so anything whose network need is only at build time can be baked into the
image and never touches the runtime allowlist. A browser is exactly that — bake it.
This skill assumes the e2e suite itself is already wired per
cloudflare-workers-e2e-playwright
(build-artifact serving, --persist-to, auth seams, 3-spec scope). What lives here is
only the sandbox-specific layer: the bake, and the two traps that only bite when
wrangler dev runs credential-free.
When to use this skill
- e2e must run inside the sandbox container: there's no host browser, and the egress
firewall blocks the Playwright CDN, so a runtime
playwright installcan't work pnpm e2ein-container fails withbrowser not found- A credential-free
wrangler dev(sandbox, or a logged-out host) logsReadyand accepts TCP, but every request hangs with no response — Traps 1-2 below - The request after an early-rejected POST (401/403 answered before the body was read)
is a 500
Network connection lostandwrangler devexits — not a sandbox trap: see Trap 3 ofcloudflare-workers-e2e-playwright - You're deciding whether in-container e2e needs new egress-allowlist entries (it needs zero — that's the point)
Do not use for:
- Wiring the e2e suite itself (the CSP/HMR trap,
--persist-to, WebAuthn/OAuth seams, test scope) — that'scloudflare-workers-e2e-playwright - Non-sandboxed environments — a plain
playwright install chromiumon the host is fine
Deliverables (completion criteria)
- Browser baked at image-build time via the
INSTALL_PLAYWRIGHTbuild arg — zero runtime egress, noinit-firewall.shchange -
ARG PLAYWRIGHT_VERSIONexactly equals@playwright/testinpackage.json, and both are bumped together (rebuild after) - Chromium
--no-sandboxgated on theDEVCONTAINERenv marker — host runs keep the real browser sandbox - The built e2e config strips the rate-limit binding (
unsafeandratelimits— the v4 key no longer hangs, see Trap 1, but stripping keeps e2e independent of the limiter) - The
.dev.varscopy that@cloudflare/vite-plugin1.x writes intodist/<worker>/is neutralised — deleted byprepare-config, or every key overridden with--var -
wrangler devbinds--ip 127.0.0.1(notlocalhost), andORIGIN+ PlaywrightbaseURLuse the same literal host
Bake the browser at image-build time
docker compose build runs before init-firewall.sh (the runtime entrypoint), so the
build has open network — fetch Chromium and its OS libs there. At runtime the CDN is
blocked, but nothing needs it: e2e drives a local wrangler dev on 127.0.0.1, and
with the OAuth round-trip replaced by the seeded-session seam there is no external
egress at all during the run.
Opt in via two knobs (the full Dockerfile block is the canonical copy in references/in-container-playwright-bake.md — don't duplicate it into your own notes; drift between copies is exactly how the version-pin trap happens):
# docker-compose.yml — then: docker compose down && docker compose build && docker compose up -d
build:
args:
INSTALL_PLAYWRIGHT: "true" # adds ~0.4 GB to the image
Key rules the block implements (the "five details" in the reference):
PLAYWRIGHT_BROWSERS_PATH=/ms-playwright + chmod a+rX (root installs, node reads),
apt-get update before --with-deps, PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 at runtime,
and the exact version pin in two places: the runtime CDN is blocked, so an
ARG PLAYWRIGHT_VERSION ≠ @playwright/test drift cannot self-heal — it surfaces as
browser not found until you bump both and rebuild.
The pin is also a compatibility choice, not just a drift guard: playwright@1.59.1's
install chromium hangs forever right after the download completes on a node:24
base (no error, no #N DONE; node:22 is fine, 1.62.1 on node:24 is fine —
verified 2026-08-22 in matatabetai). If the bake step sits at 100% with no further
output, test the pin × base combination standalone before blaming the CDN:
docker run --rm -e DEBUG=pw:install node:24 npx -y playwright@<ver> install chromium.
The variant both production users actually run (matatabetai 2026-08-22, kokemusu
2026-09-02, node:24 / playwright 1.62.1, ~3 min build): the Chromium apt libraries as an
explicit list in the root apt layer (plus fonts-noto-cjk — a Japanese UI renders tofu in
failure screenshots without it), then npx -y playwright@${PLAYWRIGHT_VERSION} install chromium as node after the Claude install, so the browser lands in
/home/node/.cache/ms-playwright (an image layer — never mount a volume there) and no
PLAYWRIGHT_BROWSERS_PATH / chmod dance is needed. The exact block is in the reference
next to the canonical one; both keep PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 and the pin.
Gate Chromium's own sandbox off only in-container (the container has NET_ADMIN but
not SYS_ADMIN, so the setuid sandbox can't initialize):
// playwright.config.ts — the sandbox Dockerfile sets ENV DEVCONTAINER=true
launchOptions: process.env.DEVCONTAINER ? { args: ["--no-sandbox"] } : {},
This is the same build-time-vs-runtime-network pattern as the Rust/Haskell toolchains in
claude-code-docker-sandbox: compiler at build time = no allowlist entry; deps at runtime
= allowlist needed.
Trap 1: the rate-limit binding hangs every local request
Symptom: wrangler dev logs Ready and the port accepts TCP, but every request hangs
with no response; the startup log says connected to remote resource.
Wrangler 3.x wires rate limiting via the unsafe binding form, which wrangler dev cannot
simulate: the dev-time proxy to the remote Cloudflare resource never completes its
handshake in a credential-free environment and blocks the whole pipeline. The v4
top-level ratelimits key does not do this — it is simulated locally (verified 2026-09-02
in kokemusu, wrangler 4.125.0, credential-free container, under both vite dev and
wrangler dev --config dist/…: a 40-request burst against a 30/60 s limiter answered
29×200 + 11×429, no hang, no connected to remote resource line). Fix for the 3.x form,
and still the recommendation for v4: strip both keys from the built config before
serving it for e2e — the limiter is fail-open, rate limiting is out of e2e scope (verify it
via cloudflare-workers-bot-scan-defense),
and a stripped binding means a burst of e2e requests can never 429 your own golden path:
// e2e/prepare-config.ts — post-build, editing dist/<bundle>/wrangler.json
const cfg = JSON.parse(readFileSync(CONFIG, "utf8"));
delete cfg.unsafe; // wrangler 3.x form (observed to hang credential-free)
delete cfg.ratelimits; // wrangler 4.x form: simulated locally since ≤ 4.125 (verified
// 2026-09-02); stripped anyway so e2e never depends on the limiter
While you are in that file: @cloudflare/vite-plugin 1.x copies .dev.vars into
dist/<worker>/ at build time, and wrangler reads .dev.vars from the directory of the
config it was given — so the developer's DEV_CSP=1 / ORIGIN=…:5273 silently override
whatever vars the config carries (verified 2026-09-02: relaxed CSP and 403s on every POST
until it was removed). Either rmSync("dist/<worker>/.dev.vars") here and write the e2e
values into cfg.vars, or pass every key with --var (CLI --var outranks the copy —
verified with --var RP_ID:vartest showing up in login/begin). The main skill's Trap 2
has the full write-up.
Trap 2: bind --ip 127.0.0.1, not localhost
Symptom: wrangler dev is "Ready", TCP connects, but the Worker never returns a byte —
no CSP error, no SQLite error, just a stall. Bound to localhost (the dev.ip default),
routing stalls on IPv4/IPv6 resolution in the container (localhost → both 127.0.0.1
and ::1). Fix: use 127.0.0.1 literally end to end — the bind, ORIGIN, and
Playwright baseURL must all agree (a host/ORIGIN mismatch also 403s mutations via the
CSRF check):
"e2e:server": "… wrangler dev --config dist/<bundle>/wrangler.json --persist-to .wrangler/state --ip 127.0.0.1 --port 5399"
Also pin dev.ip in the built config (prepare-config.ts:
cfg.dev = { ...cfg.dev, ip: "127.0.0.1" }) so a stray flag-less wrangler dev stays
consistent. When e2e hangs at connect-but-no-response, these two traps are the suspects —
rule out the bind address first (cheaper to check).
Scope boundary — what this skill does NOT cover
- The e2e suite wiring itself (build-artifact serving,
--persist-tostate trap, WebAuthn / OAuth seams, 3-spec scope) —cloudflare-workers-e2e-playwright - The sandbox environment itself (firewall, compose files, allowlist tuning) —
claude-code-docker-sandbox - Verifying rate-limiting behavior (deliberately stripped here) —
cloudflare-workers-bot-scan-defense
References
- references/in-container-playwright-bake.md — the canonical Dockerfile block, the five details that matter, bake verification, and both traps in full diagnostic detail