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 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"]
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.
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'] } }],
});
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.
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.
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.
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. 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, which is the bridge to Running Playwright Tests in GitHub Actions with Sharding. 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.