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:
Tag
Ubuntu base
Node bundled
Size (approx)
Use when
v1.49.0-focal
20.04 (Focal)
20.x
~1.4 GB
You need glibc 2.31 for very old CI machinery.
v1.49.0-jammy
22.04 (Jammy)
20.x
~1.5 GB
Default. Most teams. Still LTS for years.
v1.49.0-noble
24.04 (Noble)
20.x
~1.5 GB
You want the newest Ubuntu LTS.
v1.49.0-jammy-arm64
22.04 + arm64
20.x
~1.5 GB
Apple Silicon CI, AWS Graviton runners.
v1.49.0-noble-slim
24.04 minimal
20.x
~750 MB
Only 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.
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 buildsCOPY package.json package-lock.json ./
RUN npm ci
# Copy everything elseCOPY . .
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 dependenciesFROM 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 depsFROM 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.
Flag
What it does
Why Playwright cares
--rm
Delete the container when it exits.
Keeps your docker ps -a clean. CI runs accumulate hundreds of containers otherwise.
--ipc=host
Share 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):/work
Bind-mount current directory.
Lets you edit tests on the host, run them inside. Also where artifacts come out.
-w /work
Set working directory.
Combined with -v, makes the container behave as if it ran in your repo.
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.
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.
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.
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();
});
D1Drill 1 - Pull and run the official image
No Dockerfile. Just docker run the official image, bind-mount a tiny Playwright project, run
one test against https://app.thetestingacademy.com/playwright/ttacart/. Confirm the test
passes and a report appears in your local playwright-report directory.
Hint
Use the one-liner from section 6. The image is mcr.microsoft.com/playwright:v1.49.0-noble.
D2Drill 2 - Forget --ipc=host and observe the crash
Run the same command without --ipc=host. Run a test that opens many tabs or loads a heavy
page. Note the Chromium crash error in the trace - something about /dev/shm. Add the flag
back, observe stability.
Hint
The error reads "Target page, context or browser has been closed" plus a kernel-level shm warning.
D3Drill 3 - Write a single-stage Dockerfile
Build a Docker image that has your tests baked in. docker build -t my-tta-tests . then
docker run --rm --ipc=host my-tta-tests. Verify the image is around 1.5 GB.
Hint
Use the exact Dockerfile from section 4. Tag explicitly so you can refer to it later.
D4Drill 4 - Make it multi-stage
Refactor your Dockerfile to the two-stage version in section 5. Time both builds (time docker build ...).
Touch one test file and rebuild - the second build should reuse the deps stage.
Hint
Watch the output for => CACHED [deps...] lines on rebuild.
D5Drill 5 - Switch to the slim image
Change FROM to mcr.microsoft.com/playwright:v1.49.0-noble-slim. Run the same
suite. Confirm Chromium-only tests still pass. Run a WebKit test - it fails. Document the trade-off in
your team's README.
Hint
The slim image only carries Chromium. devices['Mobile Safari'] projects will be broken until you add WebKit back.
D6Drill 6 - docker-compose with an app + tests
Spin up a simple static server (nginx serving a folder) as ttacart-app. Set
BASE_URL=http://ttacart-app:80. Run the test container against the service name. Verify both
containers come up and tests pass.
Hint
Use the compose YAML from section 7. Static nginx image: nginx:alpine.
D7Drill 7 - Extract a trace from a failing test
Force a failure by asserting a heading that does not exist. Run inside Docker. Confirm
test-results/trace.zip appears on your host. Open it with
npx playwright show-trace test-results/trace.zip.
Hint
Make sure use.trace: 'on-first-retry' or 'retain-on-failure' in playwright.config.ts.
D8Drill 8 - Headed run in container
Wrap the test command in xvfb-run --. Run --headed. The test should pass even
though there is no host display. Record a video and confirm the page is visible in playback.
Hint
xvfb-run -- npx playwright test --headed - the -- is required to pass args through.
SSolution snippets
One-liner run (no Dockerfile)
run-once.sh
docker run --rm --ipc=host \
-v $(pwd):/work -w /work \
mcr.microsoft.com/playwright:v1.49.0-noble \
bash -c "npm ci && npx playwright test"