Home / Blog / Playwright in Docker

Playwright in Docker: when the official image is enough

Quick answer

Most teams should not write a Dockerfile for Playwright. Microsoft publishes an image holding the browsers and their system dependencies, and the version in its tag must match the Playwright version in your project — mismatch them and the tests cannot find a browser. Write your own image only when the application under test lives in it too.

This page assumes you already run something in Docker. It is not a Docker tutorial, there is nothing here about Kubernetes, and a reader who came for either is better served somewhere else today.

Containerising a Playwright suite is mostly two decisions: which image, and what it costs on a runner. Inside the first one sits a rule that takes pipelines down on a schedule — at every Playwright upgrade, in both directions, with no randomness in it at all.

Everything here targets Playwright 1.62. The commands that could be run were run on Windows 11 with Node 20.19.6, against a project pinned to the 1.62.1 patch from npm. No container was built or run while writing this page. The machine has no Docker engine and no Podman on it, so nothing below has been observed inside a container. Each block carries a note saying what was done to it: which output came out of a run here, which lines were transcribed from the documentation, and which were checked against a reference and never executed.

Do you need a Dockerfile, or just the image?

Just the image, for most teams. It is published to the Microsoft Artifact Registry, and Playwright's Docker page gives one line to pull it. There are three ordinary ways to use it, and a fourth that sits at the bottom of the documentation.

Run it directly, to reproduce a CI failure

A failure that only ever happens on the runner can be put in front of you on a laptop, because the container is the runner's environment. This is the use that ends "works on my machine" arguments.

docker run -it --rm --ipc=host \
  -v "$PWD":/work -w /work \
  mcr.microsoft.com/playwright:v1.62.1-noble /bin/bash
Docker · not executed — no container engine on the machine this was written on · every flag checked against Docker's own docker container run reference (--ipc, --rm, -i, -t, -v, -w) · the tag v1.62.1-noble confirmed present in the registry's tag list · --ipc=host and the image name are the documentation's own, the mount and working directory are ours

The documentation's version of that line stops at a shell prompt in an empty container. The mount is the part you need, because the image holds no copy of your project and no copy of the Playwright package. Once you are inside it, the two commands are the ordinary ones:

npm ci
npx playwright test --project=chromium
Playwright 1.62.1 · both commands were run here against a two-file project, outside any container · npx playwright test reported 1 passed

As the CI job's container

docs/ci runs its GitHub Actions job inside this image with options: --user 1001, and gives two reasons: the host environment stays clean, and screenshots come out of the same environment whatever operating system the runner is on. The workflow file that goes around it shows where those two keys sit and what drops out of the job once they are there.

As a base you build on

Take the official image as your FROM and add whatever it is missing for you. The case for doing that, and the Dockerfile, are further down.

Keeping the browsers in the container and the tests outside it

The last third of docs/docker reads like an appendix and answers a question the rest of the page does not: can the browsers be pinned in an image without the test code moving into one? They can. Playwright ships a server mode, and the documentation runs it inside the container while the tests stay on the host.

docker run -p 3000:3000 --rm --init -it --workdir /home/pwuser --user pwuser mcr.microsoft.com/playwright:v1.62.0-noble /bin/sh -c "npx -y playwright@1.62.0 run-server --port 3000 --host 0.0.0.0"
Docker · transcribed verbatim from docs/docker, not run here · confirmed identical at two doors: the rendered page and docs/src/docker.md at the v1.62.0 tag, where the version appears as a build placeholder
PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:3000/ npx playwright test
Playwright · transcribed from docs/docker, not run here · the same section gives playwright['chromium'].connect('ws://127.0.0.1:3000/') for code that is not using the test runner

If the tests need to reach a server on your own machine, the documented variant adds --add-host=hostmachine:host-gateway to that docker run, and the tests then ask for hostmachine where they would have said localhost. The section carries a note that the Playwright version in your tests has to match the version running in the container, which is the same rule as the next section, arriving from a different direction. We have read this section and not run it, so what is above is what the documentation says and no more.

Why do the browsers disappear when you upgrade Playwright?

Nothing changed in the image. Nothing changed in the pipeline. Somebody moved one line in package.json on Tuesday, and every job now fails on a browser executable that is not there. The error is about a browser and the cause is a string in a different file, which is why teams lose an afternoon to it.

docs/docker states the rule under Image tags: "It is recommended to always pin your Docker image to a specific version if possible. If the Playwright version in your Docker image does not match the version in your project/tests, Playwright will be unable to locate browser executables."

The documentation does not show you the failure. Playwright prints one of two messages, and which one you get depends on how the container was built.

The first is what a hand-rolled image gives you, or any machine with no image involved:

╔════════════════════════════════════════════════════════════╗
║ Looks like Playwright was just installed or updated.       ║
║ Please run the following command to download new browsers: ║
║                                                            ║
║     npx playwright install                                 ║
║                                                            ║
║ <3 Playwright Team                                         ║
╚════════════════════════════════════════════════════════════╝
Playwright 1.62.1 · produced by a run here, by pointing PLAYWRIGHT_BROWSERS_PATH at a directory holding a browser build the installed version does not want · the line above the box, naming the executable it looked for, is cut because it carries this machine's temporary path · reporter indentation removed

That advice is wrong for a container, and expensively so. Running npx playwright install inside the image downloads a second set of browsers on every job and leaves the mismatch in place for the next upgrade.

The second message is the one the official image gives:

╔════════════════════════════════════════════════════════╗
║ Looks like Playwright was just updated to 1.62.1.      ║
║ Please update docker image as well.                    ║
║ -  current: mcr.microsoft.com/playwright:v1.61.0-noble ║
║ - required: mcr.microsoft.com/playwright:v1.62.1-noble ║
║                                                        ║
║ <3 Playwright Team                                     ║
╚════════════════════════════════════════════════════════╝
Playwright 1.62.1 · produced by a run here, and not inside a container · the official image is stamped at build time with a marker file at /ms-playwright/.docker-info; that file was recreated by hand at the same path, naming a 1.61.0 image, so the same code path in playwright-core ran · the path line above the box is cut, as above

It names the tag you are on and the tag you need. The mechanism is in the image's build recipe: the Dockerfile runs playwright-core mark-docker-image with the image name as a template, which writes the image name and driver version to /ms-playwright/.docker-info. When a browser is missing, Playwright reads that file, substitutes its own version into the stored template, and prints both tags if they differ. An image built FROM node:20-bookworm never runs that command, so there is no marker to read and Playwright falls back to the first message.

Where else that version is written down

The tag is a second declaration of a version your project already declares once, and a second declaration drifts. Count where yours are before the next upgrade: the container.image in the CI definition, a docker run line in a shell script somebody wrote, a FROM in your own Dockerfile, a devcontainer definition, a compose file. Upgrading Playwright means changing all of them on the same day.

There is no tool for this. The habit is that the version lives in one place the team agrees on, and every other copy of it sits on a list somebody walks at upgrade time.

For a small illustration of how easily that list goes stale: in docs/src/docker.md at the v1.62.0 tag, every sample carries a build-time placeholder that the documentation site fills in with the current version. The devcontainer JSON in the Codespaces section carries a literal mcr.microsoft.com/playwright:v1.57.0 instead, so it does not move when the others do. That is a fact about one snippet's plumbing and nothing more. It is not a statement about which version is current, and the 1.57.0 tag is real. It is what a version string copied into a second file does, in the file that documents the rule against it.

Which tags exist

The suffix names the Ubuntu the image is built on.

Tag suffixBase image
-nobleUbuntu 24.04 LTS (Noble Numbat)
-jammyUbuntu 22.04 LTS (Jammy Jellyfish)
-resoluteUbuntu 26.04 LTS (Resolute Raccoon)

A tag with no suffix, v1.62.0, is the Noble image. The registry's own product page is where the documentation sends you for the full list.

Ask the registry rather than assembling the string yourself. The set of suffixes is not fixed, and a tag that reads like every other tag can still be absent:

curl -s https://mcr.microsoft.com/v2/playwright/tags/list \
  | tr ',' '\n' | grep -o 'v1\.62\.[0-9]*-noble' | sort -u
Bash · run here on 1 September 2026, output v1.62.0-noble and v1.62.1-noble · the route returns the whole list as one JSON document, 18,570 tags, so it wants a shell and a pipe rather than a browser

That list settles things a pattern cannot. It is where v1.62.2-noble turns out not to exist, and where a team pinned to an older image finds out that focal stops at v1.48.2 and bionic at v1.28.1, so bumping the version while keeping the suffix asks for an image nobody published. resolute arrived at v1.61.0 and runs in the other direction: pin it and you have pinned yourself above 1.61.

What is actually inside the image?

The image's own build recipe is public text, at utils/docker/Dockerfile.noble in the Playwright repository, and it is the shortest answer to what is in there. At the v1.62.0 tag it starts FROM ubuntu:noble and then:

What is not in it: the Playwright package. The documentation says so in its first paragraph, and it is why docker run … npx playwright test against a fresh container does not do what people expect. You bring Playwright, the image brings the browsers.

Alpine will not work, and the reason is a compiler target. The Firefox and WebKit builds are compiled against glibc, and the documentation states that Alpine and other musl-based distributions are not supported. A team standardised on Alpine has to make an exception for this image, and week one is a better time to find that out than week nine.

The image does not change what Playwright can drive. Three browser builds arrive in it and nothing else does: no native iOS or Android application driver, and its WebKit is Playwright's own build of the engine, which is not the Safari an iPhone actually runs. A container makes the environment repeatable and leaves the framework's reach exactly where it was.

How big is it?

903 MiB to download, 2.33 GiB on disk, for the linux/amd64 build of v1.62.0-noble. Those are two different numbers for the same image and confusing them is how a runner's disk budget goes wrong: the first is what crosses the network on a cold pull, the second is what the layers occupy once unpacked. Precisely, across its seven layers: 947,006,567 bytes compressed and 2,506,950,656 uncompressed, a ratio of 2.65.

The linux/arm64 build of the same tag is 913 MiB compressed and 2.61 GiB unpacked — near-identical to download and about 12% larger on disk. If your runners are ARM, budget for the second number, not the first.

The useful comparison is against the browsers on their own, and it can only be made loosely: the du -hs in docs/browsers adds up to 648 MiB, but it was taken on a macOS cache path and this is a Linux image, so the two are not the same measurement and we are not going to pretend a percentage of one is a percentage of the other. What survives the mismatch is the shape: the browsers are a minority of what lands on disk, and the operating system and the system libraries are the majority. That is the real answer to why the documentation says you cannot substitute Alpine, and why "just install the browsers" is not the same trade as pulling the image.

Why does Chromium crash in a container?

The Recommended Docker Configuration section of docs/docker is four sentences long and carries most of the pain on this page. It names three flags.

--ipc=host is the flag the documentation recommends whenever Chromium is in play, and its stated reason is a Chromium that runs out of memory and crashes when the flag is missing. It is already in the docker run line the docs give, so most people have copied it without knowing what it does. A container gets its own IPC namespace by default; Docker's reference describes an IPC namespace as separating the named shared memory segments, semaphores and message queues that processes use to talk at memory speed. Chromium's processes are heavy users of that. This page prints no figure for the size a container gets, because we have not opened a source that states one.

--init is recommended to avoid the special treatment a process with PID 1 receives, which the documentation names as a common cause of zombie processes. A browser spawns children and something has to reap them.

--cap-add=SYS_ADMIN is suggested for strange errors when launching Chromium, and the documentation scopes it to local development. Read it as an escalation: it is a capability grant, and you are trading isolation for a browser that starts.

Who is the container running as?

The documentation answers this plainly, under Run the image: by default the image uses the root user to run the browsers, and that disables the Chromium sandbox, which is not available to root. If you are running code you trust, such as your own end-to-end tests, it says root may be fine. The same paragraph recommends a separate user for crawling or scraping.

So there are three configurations in the documentation and they are for different jobs:

Pick one deliberately. The consequence that bites most teams is a mundane one: a job that writes a trace or an HTML report to a mounted volume as one user and reads it back as another has a file permissions problem wearing a Playwright costume.

Should the browsers be installed at build time or at run time?

The middle option most teams reach for is the one the vendor argues against, which makes this a shorter decision than it looks.

Build time, using the official image. The browsers are already there. Nothing to install, nothing to download per run, and the browser builds are tied to a Playwright version by the tag rather than by somebody remembering.

Build time, in an image of your own. The documentation's own example starts FROM node:20-bookworm and installs with a single RUN npx -y playwright@1.62.0 install --with-deps. Note where the version went: into the RUN line, a second declaration living in a Dockerfile. Building on top of the official image keeps the version in one place in the file:

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

WORKDIR /work
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

CMD ["npx", "playwright", "test"]
Dockerfile · not built — no container engine on this machine, so this file has never been through a builder · every instruction checked against Docker's Dockerfile reference · base tag confirmed present in the registry tag list · npm ci and npx playwright test were run here on the project this file copies in

Every line there is one an earlier section argued for. The FROM tag is the only version string in the file and it brings the browsers with it. npm ci is needed because the image does not carry the Playwright package. The second COPY brings the specs. Add your application, your database client or your organisation's certificates on the end, and that is the case for building at all: something other than Playwright has to be in there. "Every image we run must descend from our own base" is a real constraint in plenty of shops, and it counts as a reason on its own.

Run time. npx playwright install --with-deps as a step in the job. No image to maintain, and a download on every single run. Two flags cut it down: name one browser, as in npx playwright install --with-deps chromium, and add --only-shell when no channel is set in your config, which trades the full Chromium download for the headless shell.

Caching the browsers between runs is where teams go next, and docs/ci argues against it on two grounds. Restoring the cache takes about the time the download would have taken, and on Linux the operating-system dependencies still have to be installed, which no cache covers. Go ahead regardless and the same page tells you to key the cache on a hash of the Playwright version. Take the advice and the choice is down to two options, which a team can settle in one meeting.

Whichever of those you land on, somebody owns it at the next upgrade. Owning it is work we do — Playwright CI/CD integration.

What does the image cost you on a cold runner?

A cold runner pays either an image pull or an install step, once per job, and neither is free. A warm runner with the image already in its local store pays neither, and that advantage disappears the moment the runners are ephemeral and hosted.

Because that cost is per job, it multiplies with job count, which is one of the fixed costs counted in the arithmetic behind splitting a suite across jobs.

One of the three inputs is settled. The pull moves 903 MiB across the network and leaves 2.33 GiB on disk, measured from the registry rather than repeated from anywhere. The two that are not settled are both times, and a size is not a duration: what a 903 MiB pull costs in seconds depends on the runner's link, and we have not timed it on a hosted one.

For the install path, what can be sourced is the browser payload: docs/browsers prints a du -hs of an installed browser cache, showing Chromium at 281M, Firefox at 187M and WebKit at 180M. The command above them reads du -hs ~/Library/Caches/ms-playwright/*, which is the macOS cache path, so those figures describe a macOS machine. And a browser payload is not an image: an image also carries a base operating system and the system dependencies, which on this image outweigh the browsers. Use them as a floor for one part of the download and nothing more.

The two readings that settle it are ones you can take on your own pipeline this afternoon: time one job with the image, and time one without.

It works in the container and fails outside it

Each of these traces back to a decision above.

Questions

Which Playwright Docker image should I use?

mcr.microsoft.com/playwright, tagged with the Playwright version your project is on. At Playwright 1.62 that is v1.62.0-noble or v1.62.1-noble, depending on which patch your lockfile resolved. Three base suffixes are published: -noble for Ubuntu 24.04 LTS, -jammy for Ubuntu 22.04 LTS and -resolute for Ubuntu 26.04 LTS. A bare tag such as v1.62.0 is the Noble image. Check the tag against the registry's tag list before you commit it, because which suffixes exist changes from release to release.

Why can't Playwright find the browsers in my container?

Because the Playwright version in the image does not match the version in your project. The image carries browser builds for the version it was built against, your Playwright looks for the build number its own version expects, and it reports an executable that does not exist. The failure is deterministic: if the two versions differ, every job fails on every run. The fix is the image tag, and inside the official image the error message names both the tag you are running and the tag you need.

Do I need to write my own Dockerfile for Playwright?

Usually no. The official image already carries the browsers and their system dependencies, which is the expensive half to reproduce by hand. Two situations make a Dockerfile worth its maintenance: the application under test has to run in the same image, or your organisation requires every image to descend from an approved base. Where you can, build on top of the official image, so the FROM tag stays the only place the Playwright version is written down.

Why does Chromium crash in Docker?

Playwright's Docker documentation recommends --ipc=host whenever Chromium is in play, and its stated reason is a Chromium that runs out of memory and crashes when the flag is missing. A container gets its own IPC namespace by default, and Docker's own reference describes an IPC namespace as separating the named shared memory segments processes use to talk at memory speed, which is what Chromium's processes are doing. The same section recommends --init to avoid the zombie processes that come from PID 1 being treated specially, and suggests --cap-add=SYS_ADMIN when Chromium throws strange errors on launch during local development.

Should I cache Playwright browsers instead of using the image?

Playwright's CI documentation argues against it on two grounds: restoring the cache takes about the time the download would have taken, and on Linux the operating-system dependencies still have to be installed, which no cache covers. That leaves two options: pull an image that already holds the browsers, or install them on every run. Go ahead regardless and the same page tells you to key the cache on a hash of the Playwright version.

Two strings, and we can tell you if they match

Send the Playwright version in your package.json and the image tag your jobs actually run — the container.image, the FROM, or the docker run line, whichever your pipeline uses. Those two settle most of what this page is about in about a minute, and where they disagree we can say what else in the repository is carrying a third copy of that version.