Practice Playwright DevOps Docker setup
DevOps
Draft
Private preview - DevOps chapter, Docker deep page.
Sidebar wiring lives in the parallel global pass. Not indexed.
Curriculum . Playwright DevOps . Docker

Playwright in Docker - base images, run, compose

Why every team eventually moves Playwright into Docker, which official image to start from, how to write a sane multi-stage Dockerfile, the four docker run flags you cannot skip, and a full docker-compose.yml that boots a TTACart-style app + a tests service on a shared network. Worked example targets https://app.thetestingacademy.com/playwright/ttacart/.

3
Image bases compared
Focal, Jammy, Noble - plus the -slim variant.
6
Diagrams
3 mermaid + 3 hand-styled inline SVGs.
8
Drills
From a one-liner run to a compose+network E2E flow.
~750MB
Slim image size
vs ~1.5GB full image - half the pull on every cold runner.

01Why Docker for Playwright DevOps - docker/01

Running Playwright on a laptop is easy. Running it on twenty laptops, three CI providers, and a Mac M1 that has no Chromium binary becomes a parade of "works on my machine" tickets. Docker solves the same problem here that it solved everywhere else.

  • Reproducible browser stack - the official Playwright image already has Chromium, Firefox, and WebKit + every shared library Chromium needs (libnss, libxshmfence, libgbm). One docker pull instead of forty-five apt-get installs.
  • No host flake - dev laptop has Chrome 142, CI runner has Chrome 124, suite passes on one and not the other. Docker forces both to use the same Chromium ship inside the image.
  • CI parity - the same image that runs in CI runs on the developer's machine. Bug repro is a literal docker run away.
  • Network isolation - your tests + the system under test live in one compose network. No localhost-vs-127.0.0.1 confusion, no surprise port collisions.

Counter-point: Docker adds 700-1500 MB to the cold-start cost of a CI runner. If your suite is 15 tests and the image is pulled fresh every time, you may be slower in Docker than out. Pin the image, use registry caching, and sharding (next chapter) to amortise the pull.

02Official Playwright images registry

Microsoft publishes them at mcr.microsoft.com/playwright and tags every Playwright release against three Ubuntu LTS bases:

TagUbuntu baseNode bundledSize (approx)Use when
v1.49.0-focal20.04 (Focal)20.x~1.4 GBYou need glibc 2.31 for very old CI machinery.
v1.49.0-jammy22.04 (Jammy)20.x~1.5 GBDefault. Most teams. Still LTS for years.
v1.49.0-noble24.04 (Noble)20.x~1.5 GBYou want the newest Ubuntu LTS.
v1.49.0-jammy-arm6422.04 + arm6420.x~1.5 GBApple Silicon CI, AWS Graviton runners.
v1.49.0-noble-slim24.04 minimal20.x~750 MBOnly Chromium needed - half the size.

Pin to a specific version, never :latest. A test that passes today on v1.49.0-jammy may fail tomorrow when the tag rolls forward to v1.51.0-jammy because a default selector strategy changed. Version pinning gives you a stable contract.

03Image layer cake - size trade-offs svg 1/3

The Playwright image is layered: base OS, system libs Chromium needs, Node.js, then the browsers themselves. -slim drops Firefox and WebKit binaries - if you only ship Chromium tests, you save ~750 MB on every pull.

Full image (~1.5 GB) Ubuntu base (~80 MB) System libs - nss, gbm, dbus (~120 MB) Node 20 + npm (~100 MB) Chromium binary (~290 MB) Firefox binary (~360 MB) WebKit binary (~340 MB) Slim image (~750 MB) Ubuntu base (~80 MB) System libs - Chromium only (~80 MB) Node 20 + npm (~100 MB) Chromium binary (~290 MB) No Firefox. No WebKit. Add them via apt-get if you need them.
What you give up with -slim: Firefox and WebKit. What you save: ~50% pull time per cold runner.

The CI hot path numbers tell the story: a fresh GitHub Actions runner pulling v1.49.0-jammy over its default network spends ~40 seconds on the pull. With -noble-slim the same pull is ~22 seconds. Multiply by 4-way shard, four parallel jobs per workflow, ten workflows per day - the minutes add up.

04Custom Dockerfile - the simplest viable file mermaid 1/3

You can run Playwright tests with no Dockerfile at all - just docker run the official image and bind-mount your repo. But once you have CI in the mix, a Dockerfile gives you a stable, taggable artefact.

Dockerfile (single-stage)
FROM mcr.microsoft.com/playwright:v1.49.0-noble

WORKDIR /work

# Copy package files first for cache-friendly builds
COPY package.json package-lock.json ./

RUN npm ci

# Copy everything else
COPY . .

CMD ["npx", "playwright", "test"]
flowchart LR
  A[FROM playwright:v1.49.0-noble] --> B[WORKDIR /work]
  B --> C[COPY package*.json]
  C --> D[RUN npm ci]
  D --> E[COPY . .]
  E --> F[CMD playwright test]
  classDef pin fill:#fef3c7,stroke:#d97706
  classDef install fill:#dbeafe,stroke:#3b82f6
  classDef run fill:#dcfce7,stroke:#22c55e
  class A pin
  class D install
  class F run
              
Build order matters: package.json before source, otherwise npm ci reruns on every code change.

The build order trick: copy package.json and package-lock.json first, run npm ci, then copy the rest. Docker layers are content-addressed - if the package files do not change, the npm ci layer reuses its cache. Changing a single line of test code does not reinstall a single package.

05Multi-stage build - cache efficiency advanced

Multi-stage adds a "deps" stage so the final image carries no node_modules bloat from build tools. For Playwright this is less dramatic than for a Node prod image - we are not trying to ship a tiny prod artefact - but it still helps because it separates two cache axes: dependency install vs test source.

Dockerfile (multi-stage)
# Stage 1: install dependencies
FROM mcr.microsoft.com/playwright:v1.49.0-noble AS deps
WORKDIR /work
COPY package.json package-lock.json ./
RUN npm ci

# Stage 2: copy app, reuse node_modules from deps
FROM mcr.microsoft.com/playwright:v1.49.0-noble AS runner
WORKDIR /work
COPY --from=deps /work/node_modules ./node_modules
COPY . .

ENV CI=true
CMD ["npx", "playwright", "test", "--reporter=blob"]

Why two stages help in practice: when you have a monorepo with five Playwright projects sharing one deps lockfile, the deps stage builds once and the runner stage re-runs per project. You can also push the deps image separately and use it as a cache source for faster CI.

06docker run flags that matter svg 2/3

Four flags do most of the work. Memorise them.

FlagWhat it doesWhy Playwright cares
--rmDelete the container when it exits.Keeps your docker ps -a clean. CI runs accumulate hundreds of containers otherwise.
--ipc=hostShare host IPC namespace (and /dev/shm).Chromium uses shared memory for renderer-to-browser IPC. Default 64 MB shm is too small - Chromium crashes mid-test.
-v $(pwd):/workBind-mount current directory.Lets you edit tests on the host, run them inside. Also where artifacts come out.
-w /workSet working directory.Combined with -v, makes the container behave as if it ran in your repo.
Host machine $(pwd) tests/ playwright.config.ts playwright-report/ test-results/ -v .:/work Container - /work tests/ (same files) playwright.config.ts playwright-report/ (written here) test-results/ Container-only paths /ms-playwright (browsers) /usr/local/bin/node /dev/shm (Chromium IPC) --ipc=host mounts host /dev/shm here
Bind-mount keeps tests + artifacts visible to the host. --ipc=host stops Chromium crashing on tiny /dev/shm.
one-liner
docker run --rm --ipc=host \
  -v $(pwd):/work -w /work \
  mcr.microsoft.com/playwright:v1.49.0-noble \
  npx playwright test

07docker-compose - app + tests on a shared network mermaid 2/3

The interesting case is not running tests in a container - it is running the system under test plus the tests in two coordinated containers. Compose makes that a single file.

flowchart LR
  subgraph compose[docker-compose network: tta-net]
    APP[ttacart-app:3000]
    TESTS[playwright-tests]
  end
  TESTS -->|HTTP http://ttacart-app:3000| APP
  APP -->|API calls inside| APP_BE[Internal API]
  TESTS -->|writes| ART[/host artifacts/]
  classDef app fill:#dbeafe,stroke:#3b82f6
  classDef tests fill:#dcfce7,stroke:#22c55e
  classDef out fill:#fef3c7,stroke:#d97706
  class APP app
  class TESTS tests
  class ART out
              
Two services, one bridge network. Tests reach the app by service name, not localhost.
docker-compose.yml
services:
  ttacart-app:
    image: ttacart:local
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
    healthcheck:
      test: ["CMD", "wget", "-q", "-O-", "http://localhost:3000/health"]
      interval: 5s
      retries: 10
    networks: [tta-net]

  playwright-tests:
    build: .
    depends_on:
      ttacart-app:
        condition: service_healthy
    environment:
      BASE_URL: http://ttacart-app:3000
    ipc: host
    volumes:
      - ./playwright-report:/work/playwright-report
      - ./test-results:/work/test-results
    networks: [tta-net]

networks:
  tta-net:
    driver: bridge

Three things to notice. (1) Tests reach the app at http://ttacart-app:3000 - the service name is the DNS name on the network. (2) depends_on with service_healthy waits for the app's healthcheck before tests boot. (3) ipc: host is the compose equivalent of --ipc=host.

08Headless vs headed in a container Xvfb

In CI you want headless. On a debugging session you sometimes want headed - to see a flaky test fail live. Inside a container, "headed" means we need a virtual display because there is no real screen. Xvfb is the classic answer.

Headless (default)

  • No display server needed.
  • Fastest, lowest memory.
  • Best for CI and shard runners.
  • npx playwright test - done.

Headed (debugging)

  • Needs an X server inside the container.
  • Use xvfb-run wrapper, or the image's --init + DISPLAY=:99.
  • Stream the screen via --ipc=host + VNC sidecar.
  • xvfb-run -- npx playwright test --headed
headed run inside container
docker run --rm --ipc=host \
  -v $(pwd):/work -w /work \
  mcr.microsoft.com/playwright:v1.49.0-noble \
  bash -c "xvfb-run -- npx playwright test --headed --project=chromium"

In most teaching settings you do not need headed-in-container. Use --debug on the host outside Docker if you want the Playwright Inspector.

09Trace + screenshot extraction svg 3/3

When a test fails inside Docker, you need the trace, video, and screenshot files out. They are written inside the container at /work/test-results/ and /work/playwright-report/. With the bind-mount they appear on the host as soon as the run completes - or fails.

Inside container /work/ tests/ test-results/trace.zip test-results/screenshot.png test-results/video.webm playwright-report/ bind-mount Host filesystem $(pwd)/ tests/ (your source files) test-results/trace.zip -> npx playwright show-trace test-results/screenshot.png test-results/video.webm playwright-report/index.html (open in browser)
Artifacts written inside appear on the host instantly. Open the trace with npx playwright show-trace.

In CI, upload the playwright-report directory as a workflow artifact. We cover that in the sharding chapter where blob reports merge across shards.

10TTACart dockerized example mermaid 3/3

Putting it together. The TTACart demo at https://app.thetestingacademy.com/playwright/ttacart/ is a static SPA - we can either point our Docker tests at the public URL (read-only smoke) or boot a local copy and run isolated.

sequenceDiagram
  autonumber
  participant ME as Developer
  participant DK as docker compose
  participant APP as ttacart-app
  participant T as playwright-tests
  participant HOST as Host filesystem
  ME->>DK: 1. docker compose up --abort-on-container-exit
  DK->>APP: 2. start ttacart-app:3000
  APP-->>DK: 3. healthcheck OK
  DK->>T: 4. start playwright-tests
  T->>APP: 5. GET / and run specs
  APP-->>T: 6. responses
  T->>HOST: 7. write test-results + playwright-report
  T-->>DK: 8. exit 0 (or 1 on failure)
  DK-->>ME: 9. surfaces both exit codes
              
Full compose lifecycle - --abort-on-container-exit propagates the test exit code.
tests/cart.spec.ts
import { test, expect } from "@playwright/test";

const BASE = process.env.BASE_URL ?? "https://app.thetestingacademy.com/playwright/ttacart/";

test("home page renders three feature cards", async ({ page }) => {
  await page.goto(BASE);
  await expect(page.getByRole("heading", { name: /ttacart/i })).toBeVisible();
  await expect(page.locator("[data-testid='feature-card']")).toHaveCount(3);
});

test("product list shows at least one item", async ({ page }) => {
  await page.goto(BASE + "products");
  const rows = page.locator("[data-testid='product-row']");
  await expect(rows.first()).toBeVisible();
});