Three ways you can run Playwright in Docker

TL;DR

  • Running Playwright in Docker means shipping a container that has both your automation code and the browser binaries it drives – or splitting the browser into its own container.
  • The official mcr.microsoft.com/playwright image is the fastest place to start, but the tag must match your Playwright version and the image is multi-gigabyte.
  • Building your own image gives you a smaller base, at the cost of managing browser and system dependencies yourself.
  • A dedicated browser container keeps your app image browser-free, isolates resource usage, and scales independently.

Introduction

If you want to run Playwright in Docker, the hard part is the browser, which is usually the least portable dependency in your stack. Containerizing them is how you get the same Chromium on your laptop, in CI, and in production.

This guide covers the three ways to run Playwright in Docker, the failure modes each one hits, and when to move the browsers out of your app container entirely.

Why run Playwright in Docker?

Playwright's biggest portability problem is the browser itself. Your script might be a few hundred lines of Node, but it depends on a specific Chromium build, a long list of system libraries, and fonts that differ between your laptop, your CI runner, staging, and production. A test that passes locally and fails in CI is often just a missing dependency.

Docker removes that variable. The container carries the exact browser build and every library it needs, so the environment is identical wherever it runs:

  • Consistent CI runs – No installing browsers on each runner or debugging missing shared libraries.
  • Reproducible rendering – The same Chromium version and fonts everywhere, so screenshots and PDFs don't drift between environments.
  • Clean deploys – The browser ships with the code that drives it, or runs in its own container your app connects to.

The question isn't really whether to containerize Playwright, but how. One of the three approaches below will work depending on which is more important to you: image size, maintenance, or scaling?

Option 1: the official Playwright image

Microsoft publishes an official image with all three browser engines and their system dependencies preinstalled. Base your Dockerfile on it and your code runs without any browser setup:

FROM mcr.microsoft.com/playwright:v1.62.1-noble

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

CMD ["node", "script.js"]

One rule keeps this working: the image tag must match the Playwright version in your package.json.

The image ships browser builds for exactly one Playwright release, and a mismatch fails at runtime with a "browser executable not found" error, rather than at build time. Pin both and bump them together.

The same version discipline applies when you connect to a remote browser with Playwright's native protocol, whereas connectOverCDP is far more tolerant of version drift.

The cost is size. The image bundles Chromium, Firefox, and WebKit plus their dependencies, so expect a multi-gigabyte base before your code goes in – that weight is pulled on every CI run and deploy.

Option 2: install browsers in your own image

If you need your own base image, install the browser and its system dependencies during the build:

FROM node:22-slim

WORKDIR /app
COPY package*.json ./
RUN npm ci
RUN npx playwright install --with-deps chromium
COPY . .

CMD ["node", "script.js"]

The --with-deps flag pulls in the system libraries Chromium needs, and installing only Chromium instead of all three engines keeps the image smaller. Keep the install step before COPY . . so browser downloads stay in a cached layer and application changes don't re-download Chromium on every build.

The pitfalls both options share

However you build the image, Chromium in a container hits the same walls:

  • Shared memory. Docker caps /dev/shm at 64MB by default, and Chromium uses it heavily. Pages crash under load with cryptic render errors until you run with --shm-size=2g (or shm_size: "2g" in Compose). Avoid the --disable-dev-shm-usage launch flag as a shortcut: it makes Chrome write to /tmp instead of /dev/shm, which trades crashes for slower page loads.
  • Zombie processes. Browsers spawn subprocesses, and PID 1 in a container doesn't reap them. Run with --init so a real init process cleans up, or your container slowly fills with defunct Chromium processes.
  • Sandbox permissions. Running as a non-root user (the official image's pwuser) plus a seccomp profile that allows Chromium's sandbox syscalls is the safe fix; disabling the sandbox with --no-sandbox is the common one that's only acceptable when you fully trust every page you load.
  • Version drift. Every Playwright upgrade means a rebuilt image and a redeploy, because the browser binaries live inside your app container.
  • Resource contention. The browser and your application code compete for the same CPU and memory limits. One heavy page can starve your app.
  • Runaway sessions. A crashed script that never calls browser.close() leaves a browser alive and holding memory. In a bundled setup, you have to build that supervision yourself; a browser container gives you a TIMEOUT (30 seconds by default) and health checks that reject new sessions when CPU or memory is already high.

These are all solvable, but your application image now carries a full browser runtime, and every scaling or upgrade decision has to account for it.

Option 3: run the browsers in their own container

The alternative is to keep your app container browser-free and connect to a container that only runs browsers.

The open-source Browserless image packages Chromium behind a WebSocket endpoint with session queueing, health checks, and a debugger UI built in:

docker run --rm -p 3000:3000 \
  -e "TOKEN=your-secure-token" \
  -e "CONCURRENT=10" \
  --shm-size=2g \
  ghcr.io/browserless/chromium

Browserless requires a token. If you don't set TOKEN, it generates a random one at startup and prints it to the container logs.

Your Playwright code stays the same except for one line: launch() becomes connectOverCDP().

import { chromium } from "playwright-core";

const browser = await chromium.connectOverCDP(
  "ws://localhost:3000?token=your-secure-token",
);

const context = browser.contexts()[0];
const page = await context.newPage();
await page.goto("https://example.com");
console.log(await page.title());
await browser.close();

Use playwright-core instead of playwright, since your app no longer needs browser binaries. Your image drops to a plain Node image, browser upgrades occur by pulling a new Browserless tag, and the browser's resource usage is isolated from your app's.

In Docker Compose, the two services look like this. Set HOST=0.0.0.0 on the browser container, as Browserless binds to localhost by default and other containers can't reach it otherwise:

services:
  app:
    build: .
    environment:
      - BROWSER_WS=ws://browserless:3000?token=your-secure-token
    depends_on:
      - browserless

  browserless:
    image: ghcr.io/browserless/chromium
    environment:
      - TOKEN=6R0W53R135510
      - HOST=0.0.0.0
      - CONCURRENT=10
      - QUEUED=10
      - TIMEOUT=30000
    shm_size: "2g"
    restart: unless-stopped

Inside Compose, read the endpoint from that BROWSER_WS variable instead of hardcoding localhost, since the browser container is reachable at its service name (browserless), not localhost.

One important detail is that, though connectOverCDP is the recommended connection for Chromium, Playwright also supports its native protocol over the /chromium/playwright path via chromium.connect().

Use the native path when you need page.route() interception, APIRequestContext, or Firefox and WebKit (Browserless publishes Firefox, WebKit, Chrome, and Edge images, plus a multi image that exposes every engine on its own path – Chrome and Edge are amd64-only).

Our connection docs compare the modes.

Scaling beyond one container

The browser container model scales in ways a bundled browser can't.

CONCURRENT caps simultaneous sessions, and QUEUED (typically 1.5–2× CONCURRENT) holds overflow instead of dropping it – once the queue is full, requests get an HTTP 429 you can back off on.

The /pressure endpoint reports load, so an orchestrator or load balancer can route around busy instances. When one container isn't enough, add more behind a load balancer rather than raising CONCURRENT, as every Chrome process competes for the same CPU.

And if you'd rather not run browser infrastructure at all, the same code connects to Browserless cloud by swapping the URL:

const browser = await chromium.connectOverCDP(
  `wss://production-sfo.browserless.io/stealth?token=${TOKEN}&solveCaptchas=true`,
);

That setup gets you managed scaling, stealth mode, and CAPTCHA solving without touching Docker. Teams with compliance or data-residency requirements can run the same platform on their own hardware with the self-hosted Enterprise image.

Start with a free Browserless account, or pull ghcr.io/browserless/chromium and run it yourself.