TL;DR
This article covers how to run Playwright tests in Docker. It compares the pinned official Ubuntu image with a Debian bookworm image that has only Chromium, and explains the run flags Chromium needs. It also covers sizing workers to container CPU and memory limits, sharding across containers in GitHub Actions, and telling starved timeouts apart from real failures.
Pin an exact tag of the official image and keep it equal to the lockfile, so the Playwright version in the image matches the version in your project. Run the container with --init and --ipc=host, and set the worker count in the same command that sets the container's CPU and memory limits.
- If you run only Chromium and pull the image fresh on every job, build on
node:26-bookwormand install only Chromium withinstall --with-deps.
Say your Playwright Dockerfile started as a copy of an old example. It pins mcr.microsoft.com/playwright:v1.37.0-focal, runs npm install, then runs npx playwright install to be safe. When the package version matches the image tag, that last step is redundant: the official image already includes the browsers and their system dependencies. The image doesn't include the Playwright package, so your Dockerfile still has to install it, and that install is where the versions drift apart.
When the Playwright version in the image doesn't match the one in your project, Playwright can't find the browser executables. An old tag next to a loose version range is how that mismatch gets in.
At least that failure is loud. The expensive ones come from the run: Playwright's CI docs warn that running more workers than the detected core count causes unnecessary timeouts and failures. Without --ipc=host, Chromium can run out of memory and crash. Meanwhile workers defaults to half the logical CPU cores, and every worker starts its own browser.
Both failures have the same root: an image, a set of run flags and a worker count that nobody matched to the project or to the container's limits. Match them, and a red container run points at your code instead of at the container.
When Docker is worth it
A container earns its place when the environment is the problem. GitHub Actions can run a job inside a container with jobs.<job_id>.container, which keeps dependencies off the host and gives every run the same environment. Playwright's CI docs give screenshots and visual regression testing across operating systems as an example of where that consistent environment helps.
If you run only Chromium on a hosted Linux runner, you may not need an image at all. npx playwright install --with-deps installs the browsers and their OS dependencies in one command, and there is no image to maintain. Reach for Docker when environment drift is costing you, such as screenshot baselines that differ between a laptop and CI. Don't add it out of habit.
By default, a container has no resource constraints and can use as much of a given resource as the host's kernel scheduler allows. Each Playwright worker is an independent OS process that starts its own browser, in a container or not. An undersized container fails the same way an undersized runner did, with one more layer to debug.
Pinning the official image
The official image brings the browsers and their OS dependencies, and your Dockerfile brings the Playwright package. npm ci exits with an error instead of updating the lockfile when it doesn't match package.json, and the Playwright version in the image has to match the version in your project.
Anti-pattern
An old tag with a caret range
The Dockerfile below pins a 1.37 image, installs dependencies with npm install against a package.json that asks for ^1.37.0, and then installs browsers again:
# Anti-pattern: npm install can resolve a newer @playwright/test than the image has browsers for
FROM mcr.microsoft.com/playwright:v1.37.0-focal
WORKDIR /app
COPY package*.json ./
# package.json declares "@playwright/test": "^1.37.0"
RUN npm install
COPY . .
# Masks a version mismatch: installs browsers for whatever @playwright/test npm resolved, not the image's
RUN npx playwright install
CMD ["npx", "playwright", "test"]
When the Playwright version in the image doesn't match the version in your project, Playwright is unable to locate browser executables. The extra install step adds build time and hides which browsers run.
Fix
Pin an exact tag and keep it equal to the lockfile. v1.64.1-noble is the Playwright v1.64.1 image based on Ubuntu 24.04 LTS (Noble Numbat). This Dockerfile copies the dependency files before the source, so the npm ci layer stays cached until they change, and it fails the build when the lockfile's @playwright/test version differs from the tag:
ARG PLAYWRIGHT_VERSION=1.64.1
FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble
ARG PLAYWRIGHT_VERSION
WORKDIR /app
# Dependency files first, so the npm ci layer stays cached until they change
COPY package.json package-lock.json ./
# Fail the build when the lockfile's @playwright/test differs from the image tag
RUN node -e "const v = require('./package-lock.json').packages['node_modules/@playwright/test'].version; if (v !== process.argv[1]) { console.error('@playwright/test ' + v + ' does not match image v' + process.argv[1]); process.exit(1); }" "$PLAYWRIGHT_VERSION"
RUN npm ci
COPY . .
CMD ["npx", "playwright", "test"]
The tag lives in one ARG, so FROM and the guard read the same version. Bump PLAYWRIGHT_VERSION and the lockfile in the same commit, and keep the noble suffix as it is, so a Playwright upgrade and an OS change never land together.
Add a .dockerignore that excludes node_modules and old reports from the build context:
node_modules
playwright-report
test-results
blob-report
Debian bookworm with Chromium only
The official image includes the Playwright browsers and the browser system dependencies. If your tests run only in headless shell (no channel option set), the docs say you can avoid downloading the full Chromium browser by passing --only-shell during installation.
Building your own image takes Node.js, the Playwright browsers and the browser system dependencies, and the docs' example Dockerfile builds on node:26-bookworm. In your own image, you can install only Chromium by passing it as an argument to npx playwright install. A team asked for an official image with only Chromium, since the three-browser image was about 2GB (#29356). A maintainer replied that you can always build a Docker image with just the browser you need. The reporter confirmed that worked (#29356).
install --with-deps installs the browsers and their OS dependencies in one command, and you can name only chromium. If your tests run only in headless shell, with no channel option set, --only-shell avoids downloading the full Chromium browser:
FROM node:26-bookworm
# Keep the browsers in one fixed directory
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Chromium's headless shell and its OS dependencies, nothing else
RUN npx playwright install --with-deps --only-shell chromium
COPY . .
CMD ["npx", "playwright", "test"]
Setting PLAYWRIGHT_BROWSERS_PATH when installing tells Playwright where to download the browsers, and setting it when running tests tells Playwright where to look for them.
Installed Playwright browsers take a few hundred megabytes of disk space. An image that holds only Chromium's headless shell leaves the rest out, and on runners that pull the image fresh every job, a smaller image means a faster cold pull. Choose between Debian and Ubuntu on image size and pull time, which you can measure on your own runners.
Anti-pattern
Alpine to cut size
Once the browsers are trimmed, swapping the base for Alpine looks like the next saving. Playwright doesn't support Alpine Linux or other musl-based distributions, because the Firefox and WebKit builds target glibc.
Fix
Stay on a glibc-based distribution, and cut size by installing fewer browsers.
Which base to pick
Use the official image unless you run only Chromium and pull the image fresh on every job. With your own Debian base, you own the system dependency updates that a new official tag would otherwise bring.
Run flags Chromium needs
A correct image can still crash Chromium if you start it wrong. Playwright's Docker docs recommend --ipc=host when using Chromium, because without it Chromium can run out of memory and crash. They also recommend --init, which avoids the special treatment of the process with PID 1, a common reason for zombie processes.
Anti-pattern
Mounting the repo over /app
Mounting the whole project into the container saves a rebuild after every edit, so it's tempting:
# Anti-pattern: the host's project, node_modules included, hides what npm ci built in the image
docker run --rm -v "$(pwd)":/app -w /app playwright-tests
When you bind-mount a directory into a non-empty directory in the container, the bind mount obscures that directory's existing contents. The tests then run against the host's copy of the project, and the dependencies installed in the image stay hidden. The command also leaves out both flags Playwright's Docker docs recommend.
Fix
Build once, then run with both flags and mount only the two output directories:
docker build -t playwright-tests .
docker run --rm --init --ipc=host \
-e CI=true \
-v "$(pwd)/playwright-report:/app/playwright-report" \
-v "$(pwd)/test-results:/app/test-results" \
playwright-tests
--rm removes the container when it exits. -e sets CI=true inside the container, so the config takes its CI branch, which the next section sets up. The HTML report lands in playwright-report on the host; open its index.html there.
Root and the sandbox
By default, the image runs the browsers as root, which disables the Chromium sandbox. If Chromium throws odd launch errors while you develop locally, the docs suggest running the container with --cap-add=SYS_ADMIN. Keep that flag a local debugging step, not a CI default.
When host IPC isn't allowed
Some platforms won't let you set --ipc=host. There, run fewer workers per container. Every worker you drop is one less Chromium in the container.
Sizing workers to container limits
Worker count is where a container that passed locally starts timing out in CI. workers sets the maximum number of concurrent worker processes. It defaults to half the logical CPU cores, and it can also be set as a percentage of logical CPU cores, such as '50%'. Each worker is an independent OS process, and each worker starts its own browser.
Going past the core count costs time, too. In their CircleCI section, Playwright's CI docs warn that setting more workers than the detected core count causes unnecessary timeouts and failures. In a report, those timeouts read like flaky tests, not like a resource problem.
Anti-pattern
Stacking default-worker containers
Left at its default, workers is half of the number of logical CPU cores. Several containers on one host can together run more workers than the host has cores. In one report (#43922), a container limited to two CPUs and running about six parallel copies saw a wait for a worker event time out 6 times in 510 iterations, though it passed 100 of 100 without CPU contention. A maintainer filed it upstream with Chromium and doesn't expect a quick fix (#43922).
Fix
Set the worker count from one environment variable, in the same command that sets the container's CPU and memory limits. The config reads it in CI and falls back to a percentage locally:
import { defineConfig } from '@playwright/test';
export default defineConfig({
// In CI, the docker run command sets PW_WORKERS next to --cpus and --memory
workers: process.env.CI ? Number(process.env.PW_WORKERS ?? '1') : '50%',
});
The docker run command sets the limits and the matching worker count together:
docker run --rm --init --ipc=host \
--cpus=2 --memory=4g \
-e CI=true -e PW_WORKERS=2 \
-v "$(pwd)/playwright-report:/app/playwright-report" \
-v "$(pwd)/test-results:/app/test-results" \
playwright-tests
Raise --cpus without PW_WORKERS and the extra cores sit idle. Raise PW_WORKERS alone and you are back to oversubscribing.
Where to start
Playwright's docs recommend workers: 1 in CI, so each test gets the full system resources. Start there on shared runners. Raise the worker count to match --cpus only when the container has dedicated cores. When the suite still runs too long, scale out with shards instead of up with workers, a trade-off covered in how CPU limits affect worker performance.
GitHub Actions container jobs
In GitHub Actions, the pinned image becomes the job's environment through jobs.<job_id>.container, and the browser install step goes away. Pass Playwright's recommended Docker flags through the container's options, which takes docker create options.
Anti-pattern
Caching browser binaries
Caching the browser directory between jobs and running the install step anyway looks like a speedup:
# Anti-pattern: restoring this cache takes about as long as a fresh download, and the OS dependencies still install every run
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- run: npm ci
- uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('package-lock.json') }}
- run: npx playwright install --with-deps
- run: npx playwright test
Playwright's CI docs advise against caching browser binaries. Restoring the cache takes about as long as downloading the binaries, and on Linux the operating system dependencies still have to be installed, since they can't be cached.
Fix
Run the job inside the pinned image. The image is the cache:
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --ipc=host --init
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- run: npm ci
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-results
path: |
playwright-report/
test-results/
The upload step runs even when the test step fails, as long as nobody cancelled the workflow. Without that condition, the failed run, the one whose evidence you need, is the one that loses its report.
The cold pull
With the install step gone, pulling the image is the cost left in each job. That's where the Chromium-only Debian image pays off: on runners that pull it fresh every job, every megabyte left out is one the job doesn't download.
Sharding across containers
One container per job stops scaling at the size of one runner. Sharding splits the suite across several: each matrix job runs one container and passes --shard=x/y on the command line. In containers, every shard is a separate job that starts its own container on its own runner. That changes how you size workers and what each shard costs before its first test runs.
Workers per shard
Size workers per shard, not per suite. Each worker is an independent OS process that starts its own browser, so the workers in one shard all compete for that shard's runner. Adding shards doesn't raise that limit. In its CircleCI section, the CI docs warn that overriding the number of workers to more than the detected core count will cause unnecessary timeouts and failures.
The nullish coalescing (??) operator in the config returns its right-hand side operand when its left-hand side operand is null or undefined, and otherwise returns its left-hand side operand. Set fullyParallel: true before you shard, too: Playwright recommends it for a balanced distribution of tests across shards, especially in CI.
import { defineConfig } from '@playwright/test';
export default defineConfig({
// Recommended for a balanced distribution across shards
fullyParallel: true,
// Each shard job sets PW_WORKERS to its own runner's core count
workers: process.env.CI ? Number(process.env.PW_WORKERS ?? '1') : '50%',
});
Cold pulls per shard
Count every shard you add as one more cold pull of the image before a single test runs. The Chromium-only bookworm image pays off most here. Docker's build best practices note that a smaller base image offers fast downloads. If you built that image, point the job's container image at your own tag instead of the official one, and keep the same run flags. Add shards when one runner's workers are already busy, not before.
The shard matrix
The matrix runs four shards in the pinned image. Each shard sets PW_WORKERS from the core count its runner reports, runs its slice of the suite, and uploads its blob report:
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --ipc=host --init
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- run: npm ci
- name: Match workers to this runner's cores
run: echo "PW_WORKERS=$(nproc)" >> "$GITHUB_ENV"
- run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: blob-report-${{ matrix.shardIndex }}
path: blob-report
retention-days: 1
collect-reports:
if: ${{ !cancelled() }}
needs: [test]
runs-on: ubuntu-latest
steps:
# Gathers the blobs only; merging them into a report is a separate step
- uses: actions/download-artifact@v4
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- uses: actions/upload-artifact@v4
with:
name: all-blob-reports
path: all-blob-reports
Each shard writes its own blob report files. To merge reports from multiple shards, the sharding docs put the blob report files into a single directory, such as all-blob-reports. Blob report names contain the shard number, so they don't clash. npx playwright merge-reports reads all blob reports from the passed directory and merges them into a single report. Merging shard blob reports walks through that step.
If you'd rather not maintain that last job, Currents collects results from every container and worker into one run matched by the CI Build ID, and any CI that can create multiple containers or jobs can run Playwright sharding with it.
Starved timeouts vs real failures
Overriding the number of workers to more than the detected core count causes unnecessary timeouts and failures. Watch the timeouts. Each test gets 30 seconds by default, and that budget includes fixture setups and beforeEach hooks as well as the test function. A fixture's setup counts toward the test timeout, so a slow fixture can cause test timeouts, while each worker-scoped fixture has its own timeout, equal to the test timeout.
Retries make the pattern visible. With retries on, Playwright marks a test that failed on its first run and passed on a retry as flaky. Flaky timeouts that cluster in container runs and never show up locally point at resources, not code.
Anti-pattern
Raising every timeout
When tests time out in Docker, the quick move is to raise the test timeout until CI goes green. That hides an oversubscribed container, and every run that does fail now takes longer to fail.
Fix
Lower the worker count per container, and leave the timeouts alone. To confirm starvation first, rerun the failing shard in the container with one worker and retries on:
docker run --rm --init --ipc=host \
--cpus=2 --memory=4g \
-e CI=true -e PW_WORKERS=1 \
-v "$(pwd)/playwright-report:/app/playwright-report" \
-v "$(pwd)/test-results:/app/test-results" \
playwright-tests \
npx playwright test --shard=3/4 --retries=2
If the shard passes with one worker, the container was starved: lower PW_WORKERS for that job or add a shard. If the same test fails the same way on every attempt, it's a real bug, and no worker count will fix it.
Timeouts that aren't starvation
Some Docker timeouts have nothing to do with resources, and a rerun with one worker that still times out is how they show up. In one report, a trace recording fixture timeout of 150000ms was exceeded during setup in Docker (#38192). A maintainer replied by asking whether the test's own timeout setting was limiting the teardown time. The issue was closed as not planned. The reporter said a reproduction would be really hard to create. For those cases, work through a checklist for debugging Playwright timeouts instead of the container's limits.
Rerunning shards one at a time doesn't scale past a handful of failures. Currents' Error Explorer analyzes each failure, and grouping failures by Category and Branch shows whether timeouts or assertions make up most of them.
What the container won't fix
Docker fixes the environment, not the capacity. If the Playwright version in your Docker image does not match the version in your project, Playwright can't locate browser executables, so pin the image to a specific version. Every worker is an OS process that starts its own browser, and --cpus and --memory cap the CPU and memory the container can use. Once one container per shard, at a worker count that holds steady, still runs too long, add shards. Don't add workers.
Keep the image pinned and bump it on purpose: tag and lockfile in one commit. A tag that drifts brings back the mismatch this setup removes, where Playwright can't find its browser executables. With the version guard in the Dockerfile, that mismatch fails the build before a single test runs.
Frequently asked questions
How do I run Playwright tests in Docker?
Use the official Playwright image pinned to an exact tag, and keep that tag equal to your lockfile, as Pinning the official image shows. The image already includes the browsers and their system dependencies, but it doesn't include the Playwright package, so your Dockerfile still has to install it. When you use Chromium, Playwright's Docker docs recommend running the container with --ipc=host, because without it Chromium can run out of memory and crash.
Should I use Debian or Ubuntu as the base image for Playwright?
Use the official image unless you run only Chromium and pull the image fresh on every job. With your own Debian base, you own the system dependency updates that a new official tag would otherwise bring. As Which base to pick explains, choose between Debian and Ubuntu on image size and pull time, which you can measure on your own runners.
How many Playwright workers should I run in a Docker container?
Start with workers: 1 on shared runners, since Playwright's docs recommend workers: 1 in CI so each test gets the full system resources. Where to start covers when to scale out with shards instead.
Why does Chromium crash in a Playwright Docker container?
Without --ipc=host, Chromium can run out of memory and crash, so Playwright's Docker docs recommend that flag when using Chromium. They also recommend --init, which avoids the special treatment of the process with PID 1, a common reason for zombie processes. Run flags Chromium needs shows the full command.
Why can't Playwright find browser executables in Docker?
When the Playwright version in the image doesn't match the version in your project, Playwright can't find the browser executables. Pin an exact tag and keep it equal to the lockfile.
How do I tell resource timeouts from real Playwright test failures?
Rerun the failing shard in the container with one worker and retries on. If the shard passes with one worker, the container was starved.
How do I scale Playwright tests across Docker containers?
Shard the suite: each matrix job runs one container and passes --shard=x/y on the command line. Size workers per shard, not per suite, and add shards when one runner's workers are already busy.
Should I cache Playwright browsers in GitHub Actions?
No. Playwright's CI docs advise against caching browser binaries, because restoring the cache takes about as long as downloading the binaries, and on Linux the operating system dependencies still have to be installed. Run the job inside the pinned image instead, as the fix in Anti-pattern: Caching browser binaries shows.




