Skip to content
 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

582 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Playwright MCP on Render

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.

Deploy to Render

playwright.mp4

What it does

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.

Architecture

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_TOKEN is unset the container refuses to start, so there is no configuration in which the server is exposed anonymously (see Security).

Prerequisites

To deploy, you need:

  • A Render account — free to create; the service itself runs on the paid standard instance type (see Deploy).
  • A GitHub account, to fork this repo (the Deploy button reads render.yaml from 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.render uses a # syntax= directive and COPY --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.

Deploy

  1. Click Deploy to Render above (or fork this repo and create a new Blueprint from it).
  2. Render reads render.yaml and provisions one Docker web service (playwright-mcp) on the standard plan.
  3. Wait for the deploy to go live. Your server is at https://<your-service>.onrender.com/mcp.
  4. Copy MCP_TOKEN from the service's Environment page in the Render Dashboard — Render generated it for you. Your MCP client must send it as Authorization: Bearer <token> (see Using the app); without it every request gets a 401.
  5. 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/starter tier (512 MB), so the Blueprint defaults to standard (2 GB). Downgrade only if you've confirmed your workload fits in less.

Using the app

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.

Run locally

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-render

Once 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_TOKEN is the one variable you must supply — the container exits immediately without it. The rest of .env is convenience: outside Render there's no RENDER_EXTERNAL_HOSTNAME, so the entrypoint already falls back to PORT=10000 and --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.

Configuration

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).

Browser profile state

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>

Security

What you're actually running

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.

What the template does about it

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.yaml uses generateValue: 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 429 with a Retry-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 Authorization header 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 answered 501 rather than proxied, since the MCP transport never needs one.

What it does not do

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: web to type: pserv in render.yaml to make it a private service — no public URL, strictly safer, and the token still applies. Note it gets no RENDER_EXTERNAL_HOSTNAME, so the host check falls back to * and the startup banner prints a localhost URL; 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; 401 lines 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.

Rolling Playwright MCP

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 version field in package.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.render copies only render-entrypoint.sh, render-auth-proxy.mjs, and render-auth-limiter.mjs). Expect it to differ from the image tag; leave it alone.


Based on microsoft/playwright-mcp · Deploys on Render.

Releases

Packages

Contributors

Languages