Level 5 · Architect · Practice 03

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.

Licensing caveat (important, read this). Docker Desktop is free for personal use, education, non-commercial open-source projects, and small businesses — defined by Docker as companies with fewer than 250 employees and less than US$10 million in annual revenue. Larger organisations need a paid subscription (Pro, Team, or Business). If you are at a bigger company and don't have a licence, you have two free alternatives: (1) install the Docker Engine CLI on Linux, which is always free and open-source (docs.docker.com/engine/install), or (2) use Podman Desktop, a free and open-source drop-in replacement (podman-desktop.io) that is CLI-compatible with most docker commands. This entire exercise works with all three.
  1. 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.app to /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).

  2. Verify the install.
    docker --version
    docker run --rm hello-world

    You should see Docker print its version (Docker version 27.x.x or newer) and then the hello-world image download and print "Hello from Docker!". If that works, the engine is healthy.

  3. 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:

architect-03-docker/ ├── tests/ │ └── smoke.spec.ts ├── .dockerignore ├── Dockerfile ├── docker-compose.yml ├── package.json ├── package-lock.json └── playwright.config.ts

4 Write the Dockerfile (and friends)

playwright.config.ts
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'] } },
  ],
});
tests/smoke.spec.ts
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.

Dockerfile
# 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"]
.dockerignore
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.

docker-compose.yml
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:latest

Expected 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.

Why --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

  1. 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 with write:packages, log in with echo $PAT | docker login ghcr.io -u <your-user> --password-stdin, then docker push. Verify by pulling the same image on a different machine (or docker run after docker system prune -af).
  2. Shrink the image with a multi-stage build. Use a node:20-slim builder stage for npm ci, then copy only the node_modules folder into a mcr.microsoft.com/playwright:v1.49.0-jammy runtime stage. Compare docker images output before and after — you should trim 200–400 MB from the final image by excluding build tooling.