Dockerizing Playwright for Headless CI
The single largest source of "works locally, fails in CI" with Playwright is the host environment: a missing font library, a Chromium that crashes under memory pressure, browser binaries that do not match the installed package version. A container ends the argument. The official mcr.microsoft.com/playwright image ships every browser and every OS dependency, pinned to a specific Playwright release, so the image that passes on your machine is byte-for-byte the image that runs in the pipeline. This page builds that image, then fixes the three failures that still bite people who containerize: missing dependencies from using the wrong base, Chromium crashing without --ipc=host, and permission errors from running as the wrong user.
Why the official image, not a generic Node base
A common first attempt is FROM node:20, then npx playwright install. It downloads the browsers but not the operating-system libraries they link against, so the first test dies with an error naming a shared object — libnss3.so, libatk-1.0.so, or similar. You can chase those packages with apt-get, but the list changes with browser versions and is tedious to maintain. The official mcr.microsoft.com/playwright image already contains the exact browsers, the exact OS dependencies, and a non-root pwuser, all matched to a tagged Playwright version. Pin the tag to the same version you depend on in package.json so the container browsers never drift from the library your tests import.
The middle option — a Node base plus npx playwright install --with-deps — does work, and it is the right answer when a corporate registry blocks the Microsoft image. It costs you a browser download and an apt-get transaction on every cold build, and it reintroduces drift the moment someone bumps the Playwright dependency without rebuilding. If you take that route, treat browser binaries as a cached artifact rather than a build step, which is the subject of Caching Playwright Browsers in CI. The official image sidesteps the question entirely: a registry pull replaces the install, and the digest is a hard guarantee that every job ran the same Chromium, Firefox and WebKit build.
The Dockerfile
Use the official image as the base and add only your application. The base already installed browsers, so do not run playwright install again — it would only re-verify. This is one of the two allowed non-TypeScript blocks.
# Pin the tag to the Playwright version in package.json so browsers match the library.
FROM mcr.microsoft.com/playwright:v1.49.0-noble
WORKDIR /app
# Copy lockfiles first so the dependency layer caches until they change.
COPY package.json package-lock.json ./
RUN npm ci
# Copy the rest of the project after deps are installed.
COPY . .
# The base image ships a non-root pwuser; run as it to avoid root-owned output.
USER pwuser
# Headless is the default; CI=1 turns on the strict config branch.
ENV CI=1
# Default command runs the suite; override per shard at docker run time.
CMD ["npx", "playwright", "test"]
Layer order is the whole performance story. COPY package.json package-lock.json before npm ci means a source-only change reuses the installed node_modules layer, turning a two-minute rebuild into a few seconds. Putting USER pwuser after the copies matters too: the COPY instructions run as root and leave root-owned files, which is fine because the test process only needs to read them, but anything the suite writes at runtime must land somewhere pwuser can write. Keep /app/test-results and /app/playwright-report inside the image rather than on a root-owned bind mount, or set ownership explicitly before switching users.
The Playwright config
The config does not need anything Docker-specific, but it should branch on CI exactly as in a non-container pipeline, since the image sets CI=1. Keep it aligned with Playwright Config & Fixtures, and size workers to the CPU quota the container actually gets rather than the host core count.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
// Browsers run headless by default; no headed flag needed in a container.
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
Over-subscribing workers is the quiet cause of container flakiness: four workers each launching a browser inside a runner limited to two CPUs turns every action timeout into a race. If the suite starts failing only under load, tune the worker count and the timeout budget together, as described in Configuring Retries and Timeouts for Stable CI.
Minimal reproducible example
The shared-memory failure is easy to demonstrate on purpose. This test renders a large table and takes a full-page screenshot — two operations that allocate sizeable buffers in the renderer's shared memory. It passes on a developer machine and inside the container when --ipc=host is set, and it dies without it.
import { test, expect } from '@playwright/test';
// Reproduces the container-only failure: a page heavy enough to exhaust the
// default 64 MB /dev/shm segment that Docker hands a container.
test('renders a large report inside the container', async ({ page }) => {
await page.goto('/reports/annual');
// Thousands of nodes keep the renderer's compositor buffers resident,
// which is what fills the shared-memory segment in the first place.
const rows = page.getByRole('row');
await expect(rows).toHaveCount(5000);
// A full-page screenshot allocates another large bitmap in shared memory,
// so this is usually the line that reports "Target page has been closed".
await page.screenshot({ path: 'test-results/annual.png', fullPage: true });
// Reached only when the renderer survived: proof the segment was big enough.
await expect(page.getByRole('heading', { name: 'Annual report' })).toBeVisible();
});
Why the renderer dies without --ipc=host
Chromium is multi-process. The browser process and each renderer exchange bitmaps and compositor frames through POSIX shared memory, which on Linux lives in /dev/shm. Docker mounts that path as a tmpfs capped at 64 MB, a limit chosen for ordinary server workloads and far below what a browser wants. When the segment fills, the allocation fails, the renderer aborts, and Playwright surfaces the aftermath as a closed target or a bus error rather than as an out-of-memory message — which is why the symptom looks nothing like the cause.
Step-by-step fix
- Base on the official image, tag-pinned. Use
FROM mcr.microsoft.com/playwright:v1.49.0-noblewith the tag matching yourpackage.jsonversion so container browsers never drift from the imported library. - Install dependencies in a cached layer. Copy
package.jsonandpackage-lock.jsonfirst, runnpm ci, then copy the source, so the dependency layer only rebuilds when the lockfile changes. - Do not reinstall browsers. The base already contains them; running
playwright installagain wastes build time and risks pulling a mismatched version. - Run as the non-root pwuser. Add
USER pwuserso test output and caches are not written as root, which prevents permission errors when the host reads artifacts. - Run the container with
--ipc=host. Passdocker run --ipc=hostso Chromium can use enough shared memory; without it the browser crashes on larger pages with bus errors. - Set CI and run headless. Set
ENV CI=1so the strict config branch activates, and rely on the headless default rather than a headed flag. - Build the image once, then run it per shard. Build and push a single tag keyed to the commit, and have every parallel job pull that exact digest instead of rebuilding, so all shards test identical bytes.
Wiring the image into the pipeline
Building the image inside every parallel job wastes minutes and, worse, allows two jobs to resolve slightly different layers. Build once in a dedicated job, tag with the commit SHA, push to a registry, and let the test jobs pull. The workflow below is the second and last non-TypeScript block on this page; the sharding mechanics behind --shard are covered in Running Playwright Tests in GitHub Actions with Sharding.
# .github/workflows/e2e.yml — build once, then fan the image out across shards.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t ghcr.io/acme/pw-tests:$ .
- run: docker push ghcr.io/acme/pw-tests:$
test:
needs: build
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
steps:
- run: >
docker run --ipc=host --rm
-v $/blob-report:/app/blob-report
ghcr.io/acme/pw-tests:$
npx playwright test --shard=$/3 --reporter=blob
Troubleshooting variants
Chromium crashes with "Target closed" or a bus error
Chromium uses /dev/shm for inter-process shared memory, and Docker's default 64 MB is too small for non-trivial pages, so the renderer crashes. Run the container with --ipc=host to give Chromium the host's shared-memory namespace. As a narrower alternative, increase the segment with --shm-size=1gb, but --ipc=host is the option the Playwright project recommends for CI. On a Kubernetes runner where neither flag exists, mount an emptyDir with medium: Memory at /dev/shm to achieve the same effect. Resist the temptation to reach for --disable-dev-shm-usage: it pushes the buffers onto disk and trades a crash for a slow, timeout-prone run.
Missing-library error such as "error while loading shared libraries"
This means the base image is not the official Playwright image, or its tag does not match the installed Playwright version. Switch to mcr.microsoft.com/playwright at the matching tag rather than installing libraries by hand. If you must use a generic base, run npx playwright install --with-deps so both the browsers and their OS dependencies are installed together. The same mismatch shows up as a browser that launches but renders text as boxes, which is a missing font package rather than a missing library — and it silently breaks any visual comparison you run.
Permission denied writing test-results or report
The container is running as root while the mounted host directory is owned by another user, or the reverse. Run as the built-in pwuser and either write artifacts to a path the user owns or align the user id with --user $(id -u):$(id -g) at run time. On GitHub-hosted runners the workspace belongs to uid 1001 while pwuser is uid 1000, so a bind-mounted report directory needs an explicit chown in the job before the container starts. Capturing those artifacts is covered in Reporters & Test Artifacts.
Verification
Build and run the image to confirm all three failure modes are resolved. First, docker build -t pw-tests . completes without an apt-get step, proving the base supplies the libraries. Second, docker run --ipc=host --rm pw-tests runs the suite to completion with no bus errors on data-heavy pages. Third, run a single shard inside the container — docker run --ipc=host --rm pw-tests npx playwright test --shard=1/3 — and confirm it slices correctly.
Two further checks catch drift rather than crashes. Run docker run --rm pw-tests npx playwright --version and compare it to the version in package.json; any difference means the tag and the lockfile have parted company. Then run the same suite against a second project to prove the image is not Chromium-only, which matters if you follow Cross-Browser Execution — all three engines ship in the base, so a WebKit failure inside the container is a test problem, not a packaging one. Finally, force a failure and confirm the trace and screenshot land in a directory the host can read, since artifacts written by the wrong uid are the last permission bug to surface; see Capturing Screenshots and Video on Test Failure. The full pipeline picture lives in CI/CD Integration, under Playwright Setup & Core Architecture.
Frequently Asked Questions
Why must I run the container with --ipc=host?
Chromium stores inter-process shared memory in /dev/shm, and Docker caps that at 64 MB by default, which is too small for non-trivial pages, so the renderer crashes with a bus error. Passing --ipc=host gives Chromium the host's shared-memory namespace and removes the limit; --shm-size=1gb is a narrower fallback.
Do I need to run playwright install inside the Dockerfile?
No, not when you base on the official mcr.microsoft.com/playwright image, because it already ships the browsers and their OS dependencies matched to a Playwright version. Reinstalling only wastes build time and risks a version mismatch. Just pin the image tag to the version in your package.json.
Why run as pwuser instead of root?
Running as root makes the container write caches and artifacts as root, which causes permission errors when the host or a mounted volume reads them, and it is poor security practice. The official image ships a non-root pwuser precisely for this, so add USER pwuser and write output to a path that user owns.
Can I use an Alpine base to shrink the image?
Not for the bundled browsers. Playwright publishes its browser builds against glibc, and Alpine ships musl, so the binaries will not load even after you install every named package. If image size is the real constraint, use the -noble official tag with a single-project config and prune the browsers you do not exercise, or run the browsers remotely and keep only the test runner in the small image.
How do I get traces and reports out of the container?
Write them to a path inside the image, then either bind-mount that path at run time or copy it out with docker cp after the run. The blob reporter is the tidiest option for parallel jobs: each shard writes one blob file to a mounted directory, and a final job merges them into a single HTML report. Make sure the mounted directory is writable by pwuser before the container starts, otherwise the run passes and the report silently never appears.