Deploy Playwright MCP on Render in one click. Get a hosted, headless-Chromium MCP server your AI tools can drive over HTTP — no local browser install, no npx on every machine.
playwright.mp4
Playwright MCP is a Model Context Protocol server that lets an LLM open a real browser, navigate, click, type, and read pages through structured accessibility snapshots (not screenshots). It normally runs locally via npx @playwright/mcp. This template runs it as a single Render web service instead, so any MCP client can connect to a shared HTTPS URL.
It's a thin wrapper over the official mcr.microsoft.com/playwright/mcp image (headless Chromium already baked in) — no source changes. The wrapper adds the flags Render needs (--headless, --no-sandbox, plus --port and --allowed-hosts from the environment) and a small bearer-token gate in front of the server, because Playwright MCP has no authentication of its own.
Authenticated by default. Playwright MCP has no auth in HTTP mode and ships an RCE-equivalent tool, so this template does not publish it directly: requests must carry
Authorization: Bearer $MCP_TOKEN, and Render generates that token for you at deploy time. Read Security to understand what that does and does not protect.
For the full tool list, config options, and client setup, see the upstream README.
One Render web service runs the official Playwright MCP image behind a bearer-token gate. An MCP client speaks Streamable HTTP to /mcp over Render's TLS-terminating edge; the gate checks the token and forwards to the MCP server on loopback, which drives a headless Chromium in the same container and returns accessibility snapshots.
┌─────────────┐ HTTPS /mcp ┌────────────────────────────────────────────────┐
│ MCP client │ ──────────────► │ Render web service (Docker, standard plan) │
│ (Claude, │ + Bearer │ │
│ Cursor, …) │ token │ render-entrypoint.sh │
│ │ │ │ reads PORT, allowed hosts │
│ │ Streamable │ ▼ │
│ │ ◄────────────── │ render-auth-proxy.mjs :$PORT (public) │
└─────────────┘ snapshots │ │ 401 unless the Bearer token matches │
│ ▼ │
│ node /app/cli.js 127.0.0.1:8931 (loopback) │
│ │ │
│ ▼ │
│ headless Chromium (baked into base image) │
└────────────────────────────────────────────────┘
How a deploy is assembled:
| File | Role |
|---|---|
render.yaml |
Blueprint. Declares the single Docker web service, its plan/region, the PORT env var, and the generated MCP_TOKEN. This is what the Deploy button reads. |
Dockerfile.render |
Thin wrapper over mcr.microsoft.com/playwright/mcp (headless Chromium pre-baked). Adds only the entrypoint and the auth gate — no browser download, no source build. |
render-entrypoint.sh |
Reads PORT, resolves the allowed-hosts value, then execs the auth gate with the MCP server (bound to loopback) as its argument. |
render-auth-proxy.mjs |
PID 1. Stdlib-only Node, no dependencies: rejects any request without Authorization: Bearer $MCP_TOKEN and rate-limits repeated guessing, forwards the rest to 127.0.0.1:8931, supervises the MCP server, holds the public port closed until that server accepts, and forwards SIGTERM. |
render-auth-limiter.mjs |
The gate's rate limit, kept separate because it is the one piece with state worth testing on its own: a fixed-window cap on failed authentications, counted across all clients. Imported by the proxy, so Dockerfile.render has to copy it too. |
render-smoke-test.sh |
Builds the image and checks the wrapper end to end: fails closed without MCP_TOKEN, 401 without the header, 429 after repeated bad ones, a handshake and a real browser tool call with the right one, clean SIGTERM. CI runs this on every PR. |
.env.example |
Documents the same knobs for running the container locally. |
Key properties:
- Thin wrapper, no fork of the tool. The Playwright MCP version is pinned by the base-image tag in
Dockerfile.render; upgrades are a one-line tag bump (see Rolling Playwright MCP). - No database, no disk, nothing to fill in. The one secret,
MCP_TOKEN, is generated by Render at deploy time. The browser keeps upstream's default persistent profile, so logins carry across requests for the life of the instance — convenient for a server that's yours alone, which is what this template assumes (see Configuration). - Authenticated, and fails closed. The gate is added by this template, not upstream. If
MCP_TOKENis unset the container refuses to start, so there is no configuration in which the server is exposed anonymously (see Security).
To deploy, you need:
- A Render account — free to create; the service itself runs on the paid
standardinstance type (see Deploy). - A GitHub account, to fork this repo (the Deploy button reads
render.yamlfrom a repo you own).
No API keys or third-party accounts are required. The one secret, MCP_TOKEN, is generated for you at deploy time — you only need to copy it into your MCP client (see Deploy).
To also run it locally (optional — see Run locally):
- Docker with BuildKit (Docker Desktop 4.x+, or Docker Engine 23+).
Dockerfile.renderuses a# syntax=directive andCOPY --chmod, both BuildKit features. - An MCP client to point at it (e.g. Claude Code, Cursor), or just
curl.
You do not need Node.js, Playwright, or a local Chromium — the base image bakes all of that in.
- Click Deploy to Render above (or fork this repo and create a new Blueprint from it).
- Render reads
render.yamland provisions one Docker web service (playwright-mcp) on thestandardplan. - Wait for the deploy to go live. Your server is at
https://<your-service>.onrender.com/mcp. - Copy
MCP_TOKENfrom the service's Environment page in the Render Dashboard — Render generated it for you. Your MCP client must send it asAuthorization: Bearer <token>(see Using the app); without it every request gets a401. - Read Security before pointing anything sensitive at it. The token is the only thing standing between the internet and code execution in this container, so treat it like a password — and tighten further if you can.
Plan sizing: headless Chromium OOMs on the free/
startertier (512 MB), so the Blueprint defaults tostandard(2 GB). Downgrade only if you've confirmed your workload fits in less.
Point any MCP client at your service's /mcp endpoint, sending MCP_TOKEN as a bearer token. For example, with Claude Code:
claude mcp add --transport http playwright https://<your-service>.onrender.com/mcp \
--header "Authorization: Bearer <your-MCP_TOKEN>"Or add it to a client config directly:
{
"mcpServers": {
"playwright": {
"url": "https://<your-service>.onrender.com/mcp",
"headers": {
"Authorization": "Bearer <your-MCP_TOKEN>"
}
}
}
}If the client reports a 401, the header is missing or the token doesn't match the current value in the Dashboard.
Then ask your assistant to browse — e.g. "Open example.com and give me the page title and the main heading." It will call the Playwright MCP tools against your hosted browser and return the result.
Optional — the deploy path above needs none of this. Useful if you want to change render-entrypoint.sh and see the effect before pushing.
git clone https://github.com/render-examples/playwright-mcp-render.git
cd playwright-mcp-render
cp .env.example .env # then set MCP_TOKEN, e.g. to `openssl rand -hex 32`
docker build -f Dockerfile.render -t playwright-mcp-render .
docker run --rm --env-file .env -p 10000:10000 playwright-mcp-renderOnce ready, the container prints upstream's own Listening on … and then the gate's [auth] Bearer-token gate listening … — in that order, because the gate holds the public port closed until the server behind it accepts, so the last line is the one that means the service is reachable. (The [startup] line above it prints an https:// URL — that scheme is for the deployed service; locally, use http.) Verify the server with an MCP handshake — export the same token you put in .env first, so the header below resolves:
curl -sS -X POST http://localhost:10000/mcp \
-H "Authorization: Bearer $MCP_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'You should get back a serverInfo block naming Playwright; drop the Authorization header and you should get 401 Unauthorized. Point a client at http://localhost:10000/mcp the same way you would the deployed URL.
MCP_TOKENis the one variable you must supply — the container exits immediately without it. The rest of.envis convenience: outside Render there's noRENDER_EXTERNAL_HOSTNAME, so the entrypoint already falls back toPORT=10000and--allowed-hosts *.
Prefer to skip Docker entirely? Upstream runs the same server directly: npx @playwright/mcp@latest --port 10000 — note that resolves to whatever npm publishes, not the image tag this template pins, and it has no auth gate (that's this template's addition). That path is also not what Render deploys, so verify changes in the container before you push.
Everything is set in render.yaml; .env.example documents the same knobs for running locally.
| Env var | Default | What it's for |
|---|---|---|
PORT |
10000 |
Port the auth gate binds to; Render routes to it. |
MCP_TOKEN |
generated by Render | Required. Bearer token every request must present. The container refuses to start without it. Rotate by editing the value (or clicking Generate again) in the Dashboard and updating your clients. |
UPSTREAM_PORT |
8931 |
Loopback port the MCP server listens on behind the gate. Change only if it collides with something inside the container. |
.env.example also lists PLAYWRIGHT_MCP_HOST, PLAYWRIGHT_MCP_HEADLESS, and PLAYWRIGHT_MCP_NO_SANDBOX. These matter only when you run the server directly (outside this image) — on Render, render-entrypoint.sh always passes the equivalent CLI flags, and CLI flags win, so setting them in the Render dashboard has no effect. The one exception is PLAYWRIGHT_MCP_ALLOWED_HOSTS, which the entrypoint does honor.
The entrypoint scopes the server's host check to your service's own onrender.com hostname automatically via Render's RENDER_EXTERNAL_HOSTNAME. If you add a custom domain, requests to it will be rejected by the host check until you set PLAYWRIGHT_MCP_ALLOWED_HOSTS (comma-separated, e.g. myapp.com,myapp.onrender.com; * disables the check).
The entrypoint passes no profile flags, so you get Playwright MCP's default: a persistent profile on the container's filesystem (~/.cache/ms-playwright/mcp-*). Two consequences worth knowing:
- Logins survive between calls — usually what you want. Authenticate once through the hosted browser and later sessions reuse the session. Every client pointed at the URL shares that one profile, so this assumes the service is yours (see Security). No Disk is attached, so the profile is wiped on restart or redeploy.
- Concurrent clients can conflict. Upstream notes a persistent profile "can only be used by one browser instance at a time, so concurrent MCP clients sharing the same workspace will conflict" — so two editors on one URL may collide. Scaling past one instance also gives each instance its own profile.
To change either, edit render-entrypoint.sh:
| Want | Add |
|---|---|
| A fresh in-memory profile per session, discarded on close | --isolated |
| A profile that survives redeploys (saved logins) | a Render Disk plus --user-data-dir <mount-path> |
Playwright MCP exposes browser_run_code_unsafe, described upstream as: "Run a Playwright code snippet. Unsafe: executes arbitrary JavaScript in the Playwright server process and is RCE-equivalent." On the pinned version it is listed under Core automation, not one of the opt-in capabilities, and --caps only enables additional capabilities (vision, pdf, devtools) — so it cannot drop this tool, and no flag excludes individual tools. Whoever can call this server can run code in your container as the container user, and reach your other Render services over the private network.
Upstream ships no authentication in HTTP mode, which is defensible when the server is npx on your laptop's loopback. On Render it gets a public URL, so this template adds the missing door.
render-auth-proxy.mjs is the only thing listening on $PORT. It answers 401 to any request without Authorization: Bearer $MCP_TOKEN and forwards the rest to the MCP server, which binds to 127.0.0.1 and is never published. Specifically:
- Fails closed. No
MCP_TOKEN, no start — there is deliberately no flag to disable the check, so no misconfiguration leaves the server anonymous. - Generated secret, not a default.
render.yamlusesgenerateValue: true, so each service gets its own token — generated once when the service is created and stable across redeploys, so clients keep working — and there is no shared credential in this repo to leak. - Constant-time comparison of SHA-256 digests, so the check doesn't leak the token a byte at a time, and a token prefix is not accepted.
- Never logs the token. Rejections log method, path, and
401— nothing presented by the client. - Rate-limits guessing. Failed attempts share one global budget: 10 in a minute and the rest of that window gets
429with aRetry-After, plus one log line rather than one per attempt. The budget is spent only by requests that were going to be refused anyway, so a valid token is never subject to it — the lockout can't be aimed at you, and you can always get back in while an attacker is locked out. It's deliberately not per client: this gate fronts a single shared secret, so what's worth guaranteeing is a ceiling on the total guess rate, and a per-address limit doesn't provide one — an attacker rotating source addresses just gets a fresh budget each time. - Stops the token at the door. The
Authorizationheader is not forwarded to the MCP server, along with the hop-by-hop headers a proxy is required to drop. Connection upgrades (WebSocket and the like) are answered501rather than proxied, since the MCP transport never needs one.
This is one door, not defense in depth. It does not sandbox the RCE, cap what an authenticated caller can do, or give you per-client identity — everyone who has the token is the same principal, and the rate limit above slows guessing, not misuse of a token that already leaked. If you can, layer something stronger on top:
- Restrict who can reach the service at all. Inbound IP rules scoped to your known addresses (Scale and Enterprise plans) mean a stolen token isn't usable from anywhere else.
- Don't expose it publicly at all. If your MCP client is another Render service, change
type: webtotype: pservinrender.yamlto make it a private service — no public URL, strictly safer, and the token still applies. Note it gets noRENDER_EXTERNAL_HOSTNAME, so the host check falls back to*and the startup banner prints alocalhostURL; reach it over the private network. - Treat the token as a password. Keep it out of shared configs and issue trackers, rotate it in the Dashboard if it may have leaked (a redeploy picks up the new value), and suspend or delete the service when you're done with it.
- Watch it. The service's logs and metrics are how you'd notice use you didn't initiate;
401lines mean someone is knocking.
One consequence of that shared budget is worth knowing before you debug against it: while someone is exhausting it, a request carrying the wrong token gets 429 rather than 401. Anyone can hold it empty indefinitely — ten refused requests a minute is all it takes — so on a service that attracts background scanning this may be the normal state rather than an unusual one. If you're testing with a token that turns out to be stale (rotated in the Dashboard, or copied from an older service), the status code won't tell you that, and the fix is to check the token rather than to wait out the Retry-After. A request with the right token is unaffected either way, which is the reason this trade is acceptable: the budget can be emptied by anyone, but the lockout it produces can't be aimed at you.
The host check the entrypoint sets up (see Configuration) is a Host-header check against DNS rebinding — not access control. It does nothing to stop a direct request carrying a valid token.
The version is pinned in exactly one place: the base-image tag in Dockerfile.render. To bump, change that tag, commit, and redeploy. (runtime: docker images don't auto-deploy when a tag moves — a fresh deploy pulls the new base.)
Then re-check the two claims in Security against the new tag's upstream README: whether browser_run_code_unsafe is still a non-opt-in Core automation tool, and whether --caps still only adds capabilities. Update that section's permalink to the new tag either way — it's the only other place a version literal appears, because a permalink has to carry one.
Then re-verify the gate, since it depends on upstream's HTTP surface. npm run test:wrapper covers the gate's own logic in seconds without Docker (the failure budget, and the proxy answering 401/429/200 over a stub upstream), and ./render-smoke-test.sh builds the new image and checks it end to end (401 without the header, 429 after repeated bad tokens, a handshake with the right one, a real tool call through the SSE path, clean shutdown). CI runs both on every PR, so opening one is equivalent. If upstream ever ships real authentication, prefer it and delete render-auth-proxy.mjs — this file exists only to fill that gap.
Not a version to keep in sync: the
versionfield inpackage.json. This repo is a fork of microsoft/playwright-mcp, so that field is upstream's own npm release marker — and the deploy never uses it (Dockerfile.rendercopies onlyrender-entrypoint.sh,render-auth-proxy.mjs, andrender-auth-limiter.mjs). Expect it to differ from the image tag; leave it alone.
Based on microsoft/playwright-mcp · Deploys on Render.