Containerise Playwright with Docker
Write a Dockerfile that builds a clean, reproducible test image, run your Playwright suite inside it, and wire in a bind mount so iteration stays fast. No "works on my machine" — the image runs identically on your laptop and on a CI runner.
1 Goal
You will build a Docker image based on the official Playwright image, copy your test code in, and expose a repeatable docker run command that executes the whole suite and emits an HTML report to your host machine. When you are done, docker run --rm resync-playwright-architect will execute two smoke tests against public targets and print a green summary. Anybody on your team — any OS, any machine — can reproduce the result byte-for-byte as long as they have Docker installed.
2 Tool install — Docker Desktop
Docker Desktop is the all-in-one free installer that gives you the Docker Engine, CLI, and a GUI. Download it from docker.com/products/docker-desktop.
docker commands. This entire exercise works with all three.- Install Docker.
Download Docker Desktop for Windows from docker.com. Run the installer — it will enable WSL 2 and the Virtual Machine Platform Windows features for you.
winget install Docker.DockerDesktop
Reboot when prompted, then launch Docker Desktop and wait for the whale icon in the system tray to stop animating.
Download Docker Desktop for Mac (pick the Apple Silicon or Intel build that matches your hardware) and drag
Docker.appto/Applications. Or:brew install --cask docker
On Linux you have two routes. Free-for-everyone route — install Docker Engine CLI directly (no Desktop needed):
# Ubuntu / Debian curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER # log out and back in so the group membership takes effect
Or install Docker Desktop for Linux from docs.docker.com/desktop/install/linux (same licence terms as above).
- Verify the install.
docker --version docker run --rm hello-world
You should see Docker print its version (
Docker version 27.x.xor newer) and then thehello-worldimage download and print "Hello from Docker!". If that works, the engine is healthy. - Pre-pull the Playwright base image (optional but avoids a long first build):
docker pull mcr.microsoft.com/playwright:v1.49.0-jammy
This image is published by Microsoft on the Microsoft Container Registry, is free to pull, and ships with Node, Chromium, Firefox, WebKit, and all their Linux dependencies pre-installed.
3 Project setup
Create a minimal Playwright project — this is the same skeleton as Practice 01, trimmed:
mkdir architect-03-docker cd architect-03-docker npm init -y npm install --save-dev @playwright/test@1.49.0
Final layout:
4 Write the Dockerfile (and friends)
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'https://playwright.dev',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});
import { test, expect } from '@playwright/test';
test('playwright.dev home page loads', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/Playwright/);
await expect(page.getByRole('heading', { name: /Playwright enables reliable/i })).toBeVisible();
});
test('jsonplaceholder API returns a known post', async ({ request }) => {
const res = await request.get('https://jsonplaceholder.typicode.com/posts/1');
expect(res.status()).toBe(200);
const body = await res.json();
expect(body).toMatchObject({ id: 1, userId: 1 });
});
Now the Dockerfile itself. It uses a pinned Playwright base image, caches npm ci in its own layer so repeat builds are fast, and drops privileges to an unprivileged user for the test run.
# Pin to an exact Playwright release so the browser binaries in the image # match the @playwright/test version in package.json. FROM mcr.microsoft.com/playwright:v1.49.0-jammy # Microsoft's image ships with Node 20 and a non-root user "pwuser" already. WORKDIR /work # Install production + dev dependencies first so they cache independently # of the test sources. A code-only change then rebuilds in ~2 seconds. COPY package.json package-lock.json ./ RUN npm ci --no-audit --no-fund # Copy the rest of the project. COPY . . # Run as the unprivileged user provided by the base image. RUN chown -R pwuser:pwuser /work USER pwuser # CI=1 turns on retries + forbidOnly in the config. ENV CI=1 # Default command: run the whole suite with list + html reporters. CMD ["npx", "playwright", "test", "--reporter=list,html"]
node_modules npm-debug.log playwright-report test-results .git .gitignore .env .env.* Dockerfile docker-compose.yml README.md
The .dockerignore file is what keeps the build fast and the image small — without it, Docker sends your entire node_modules/ tree to the daemon as build context and your image balloons by hundreds of MB.
services:
tests:
build: .
image: resync-playwright-architect:latest
# Mount the host's test sources and report output so you can iterate
# without rebuilding the image each time.
volumes:
- ./tests:/work/tests:ro
- ./playwright.config.ts:/work/playwright.config.ts:ro
- ./playwright-report:/work/playwright-report
- ./test-results:/work/test-results
environment:
- CI=1
# IPC=host prevents Chromium crashing on tiny /dev/shm inside the container.
ipc: host
# The default CMD in the Dockerfile runs the tests.
command: npx playwright test --reporter=list,html
5 Run & verify
Build the image:
docker build -t resync-playwright-architect:latest .
Expected tail:
=> [5/5] RUN chown -R pwuser:pwuser /work 0.4s => exporting to image 0.2s => => exporting layers 0.1s => => writing image sha256:e8b4... 0.0s => => naming to docker.io/library/resync-playwright-architect:latest 0.0s
Run the suite in one shot:
docker run --rm --ipc=host \ -v "$(pwd)/playwright-report:/work/playwright-report" \ resync-playwright-architect:latest
On Windows PowerShell:
docker run --rm --ipc=host `
-v "${PWD}/playwright-report:/work/playwright-report" `
resync-playwright-architect:latestExpected output:
Running 2 tests using 2 workers ✓ [chromium] › smoke.spec.ts:3:1 › playwright.dev home page loads (1.9s) ✓ [chromium] › smoke.spec.ts:9:1 › jsonplaceholder API returns a known post (0.4s) 2 passed (3.0s) To open last HTML report run: npx playwright show-report
The host folder ./playwright-report will now contain an index.html you can open in any browser — the HTML report was written inside the container and bind-mounted out.
Iterate quickly with compose:
docker compose up --build
After the first build, subsequent runs skip the npm install layer and finish in seconds. Edit a test file on the host, run docker compose up again — the volume mount means you do not rebuild the image to pick up the change.
--ipc=host? Chromium uses POSIX shared memory for its multi-process architecture. Docker's default /dev/shm is only 64 MB, so heavy tests crash with SIGBUS. --ipc=host gives the container the host's shared-memory namespace. The Playwright docs recommend it for exactly this reason.Confirm the image is reproducible by rebuilding on a clean docker with docker build --no-cache -t resync-playwright-architect:verify . and re-running — the two test runs should be byte-identical in pass/fail outcome.
6 Troubleshooting
"browser has disconnected" or SIGBUS crashes mid-test
You forgot --ipc=host. The default 64 MB /dev/shm is not enough for Chromium. Either add the flag as shown above, or set --shm-size=1gb on the docker run command. The compose file already has this via ipc: host.
"version mismatch between @playwright/test and browsers"
The @playwright/test version in package.json does not match the tag on the base image. Pin them together — if FROM mcr.microsoft.com/playwright:v1.49.0-jammy, then "@playwright/test": "1.49.0" in package.json. Do not use floating tags like v1-jammy, they upgrade silently and break reproducibility.
Build is very slow every single time
You are missing or ignoring the layer cache. Check: (a) package.json / package-lock.json are copied before the rest of the code — any edit to package.json busts the npm ci cache; (b) a .dockerignore exists and excludes node_modules, .git, and playwright-report; (c) you are not passing --no-cache in your build command by accident.
"permission denied" writing the HTML report on Linux
The bind-mounted ./playwright-report directory is owned by root on the host but the container runs as pwuser (UID 1000). Either mkdir -p playwright-report && chown 1000:1000 playwright-report on the host, or drop the USER pwuser line from the Dockerfile while you iterate. Windows and macOS Docker Desktop handle this automatically via the 9P / osxfs mount translation.
7 Challenge
Level up
- Push the image to GitHub Container Registry (ghcr.io), which is free for public images. Tag your image as
ghcr.io/<your-user>/resync-playwright-architect:latest, create a classic personal access token withwrite:packages, log in withecho $PAT | docker login ghcr.io -u <your-user> --password-stdin, thendocker push. Verify by pulling the same image on a different machine (ordocker runafterdocker system prune -af). - Shrink the image with a multi-stage build. Use a
node:20-slimbuilder stage fornpm ci, then copy only thenode_modulesfolder into amcr.microsoft.com/playwright:v1.49.0-jammyruntime stage. Comparedocker imagesoutput before and after — you should trim 200–400 MB from the final image by excluding build tooling.