Home / Blog / Playwright in Docker
Playwright in Docker: when the official image is enough
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 suffix | Base image |
|---|---|
-noble | Ubuntu 24.04 LTS (Noble Numbat) |
-jammy | Ubuntu 22.04 LTS (Jammy Jellyfish) |
-resolute | Ubuntu 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:
- Node, plus the tools a Node base image would have given you. Node arrives
from NodeSource, with a build argument reading 24 that the file annotates as generated by a
script.
git,openssh-clientandyarncome with it, under a comment about parity with the Node images. - The browsers, and their system dependencies. Chromium, Firefox and WebKit are installed in three separate layers, with a comment saying the split is there so the layers can be pulled in parallel. Installing Chromium also brings the headless shell and ffmpeg. The system dependencies go in first, in their own layer, and they are the expensive half — the part a hand-rolled Node image is missing when Chromium refuses to start.
- An account called
pwuser, created withadduser. - Browsers at
/ms-playwright, set asPLAYWRIGHT_BROWSERS_PATHin the image environment, with the browser directories made world-readable so a process running under an arbitrary UID can still start them.
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:
- No
--userat all. Root, no Chromium sandbox. The End-to-end tests command is this one. --user pwuser, with a seccomp profile. The Crawling and scraping command adds--security-opt seccomp=seccomp_profile.json, and the profile is Docker's default one withclone,setnsandunshareallowed — Chromium's sandbox asking permission to create user namespaces.--user 1001. A numeric UID, which is whatdocs/cipasses on its container job.
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.
- The upgrade that forgot the tag. A Playwright version bump is a two-file change at minimum. Put the second file on the pull request that does the first.
- The screenshots that changed for no reason. A different base image means different fonts and different text rendering. Consistent screenshots across operating systems is one of the documentation's reasons for the container in the first place, which also means moving into one invalidates the baselines you took outside it. That happens once, and people mistake it for a bug.
- Chromium starts on the laptop and dies in CI. Check whether
--ipc=hostsurvived the trip from the command you ran by hand into the pipeline definition. - The artifact nobody can open. Written by one user, read by another. Look
at which
--userthe job passes before you look at Playwright. - The container cannot reach the application under test. A localhost inside
a container is not the localhost your application is listening on. The documentation covers
--add-hostfor the server case; beyond that it is a networking decision, and this page stops at the symptom. - Tests that fail intermittently in the container and nowhere else are a different problem, and the four causes of a flaky Playwright test is where to take them.
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.