Playwright's browser binaries depend on around two dozen system libraries, and mismatches between your laptop's library versions and CI's cause failures that have nothing to do with your tests. The official Playwright Docker image at mcr.microsoft.com/playwright packages all three browsers and their dependencies into a single image that runs identically anywhere, as long as the image tag matches your @playwright/test version in package.json. This guide covers writing a Dockerfile, mounting test reports back to the host, Docker Compose for testing against a local application stack, and integrating the container into GitHub Actions.
Why Docker Matters for QA
Browser test suites are unusually sensitive to their environment. Playwright installs Chromium, Firefox, and WebKit binaries that depend on specific system libraries: libglib, libnss, libatk, and around two dozen others. On your laptop those libraries are probably the right version because you installed Playwright recently. On a colleague's machine from 2022, they might not be. On a CI runner provisioned six months ago with a different Ubuntu image, the mismatch becomes a build failure at 2am.
The traditional fix is a wiki page titled "Dev Setup" that gets out of date the moment someone writes it. Docker's fix is a file in your repository called Dockerfile that describes the exact environment your tests need. Anyone who runs docker build gets the same environment, no matter what OS they're running or what they installed last week.
For QA specifically, Docker brings three concrete benefits. First, environment parity: your tests run inside the same container locally and in CI, so a passing test locally means something. Second, no setup overhead: a new team member can run your entire test suite with two commands without installing Node, Playwright, or any browser dependencies. Third, isolation: test runs don't interfere with each other or with whatever else is installed on the host machine.
Docker Basics for Testers
If you haven't worked with Docker before, the mental model is straightforward. An image is a snapshot of a filesystem: an operating system, installed packages, your code, and environment variables, all frozen at a point in time. A container is a running instance of an image: think of the image as a class and the container as an object.
You build an image from a Dockerfile, a text file that lists the steps to construct it. You run a container from an image with docker run. When the container finishes, it stops. Nothing it did changes the image.
# Pull an existing image from a registry
docker pull node:20-slim
# Run a container from that image and execute a command
docker run node:20-slim node --version
# Build an image from a Dockerfile in the current directory
docker build -t my-playwright-tests .
# Run a container from the image you just built
docker run my-playwright-testsThe key concept for testing workflows is that containers are ephemeral by default. Files created inside a container disappear when it stops, including your test reports. You'll solve this with volume mounts, which we'll cover in the section on running tests.
Images are composed of layers. Each instruction in a Dockerfile creates a new layer, and Docker caches layers that haven't changed. This is what makes rebuilds fast: if you change your test code but not your dependencies, Docker reuses the cached layer that installed those dependencies and only rebuilds the layers that changed.
The Official Playwright Docker Image
The Playwright team publishes and maintains an official Docker image at mcr.microsoft.com/playwright. This image comes with everything browser tests need: Node.js, the Playwright package itself, and all three browser binaries (Chromium, Firefox, WebKit) with their system library dependencies pre-installed.
# Pull the image matching your Playwright version
docker pull mcr.microsoft.com/playwright:v1.52.0-jammy
# See what's inside
docker run --rm mcr.microsoft.com/playwright:v1.52.0-jammy node --version
docker run --rm mcr.microsoft.com/playwright:v1.52.0-jammy npx playwright --versionThe tag format is v{playwright-version}-{ubuntu-codename}. jammy is Ubuntu 22.04, which is the most stable and widely used option. There's also a noble variant for Ubuntu 24.04.
The image is large (around 1.8GB) because it contains three full browser installations. That size is the price of having everything pre-installed and not needing to run playwright install --with-deps at container start. For CI environments where you're pulling the image repeatedly, this is a good tradeoff because Docker layer caching means you only download it once per runner unless the tag changes.
The critical rule: the image tag must match your @playwright/test version in package.json. If you use v1.52.0-jammy but your project has "@playwright/test": "1.50.0", the API mismatches will cause confusing failures. Pin both.
Writing a Dockerfile for Your Playwright Project
Start with the official Playwright image as your base. Here's a complete, production-ready Dockerfile:
# Use the official Playwright image — pin the version to match package.json
FROM mcr.microsoft.com/playwright:v1.52.0-jammy
# Set the working directory inside the container
WORKDIR /app
# Copy package files first for better layer caching
# This layer is rebuilt only when dependencies change
COPY package.json package-lock.json ./
# Install project dependencies (browsers are already in the base image)
RUN npm ci
# Copy the rest of the project
COPY . .
# Default command: run all tests
CMD ["npx", "playwright", "test"]The order of COPY and RUN instructions matters for caching. By copying package.json and package-lock.json first and running npm ci before copying the rest of the source code, you ensure that the dependency installation layer is cached and reused whenever you change test files. If you copied everything first and then ran npm ci, any change to any file would invalidate the dependency cache.
Notice there's no npx playwright install step. The base image already has the browsers. This makes the build significantly faster than a setup that starts from a plain Node image.
Add a .dockerignore file alongside your Dockerfile to exclude files that don't belong in the image:
# .dockerignore
node_modules
playwright-report
test-results
.git
.github
*.mdExcluding node_modules is especially important. Without this, Docker would copy your local node_modules into the image before running npm ci, which wastes time and can introduce platform-specific binaries that don't work inside the Linux container.
Build the image:
docker build -t playwright-tests:local .The -t flag tags the image with a name. playwright-tests:local is a convention for distinguishing locally built images from registry images.
Running Tests in a Container
Once the image is built, running tests is one command:
docker run --rm playwright-tests:local--rm automatically removes the container when it finishes. Without it, stopped containers accumulate and consume disk space.
The problem is that test reports are written inside the container and disappear when it stops. Fix this with a volume mount using -v:
docker run --rm \
-v $(pwd)/playwright-report:/app/playwright-report \
-v $(pwd)/test-results:/app/test-results \
playwright-tests:localThe -v flag maps a host directory to a container directory. When Playwright writes the HTML report to /app/playwright-report inside the container, it actually writes to $(pwd)/playwright-report on your host machine. After the container stops, the report is there, ready to open.
On Windows, replace $(pwd) with %cd% in Command Prompt or ${PWD} in PowerShell:
docker run --rm `
-v ${PWD}/playwright-report:/app/playwright-report `
-v ${PWD}/test-results:/app/test-results `
playwright-tests:localTo run a subset of tests, override the default CMD by appending arguments:
# Run only tests matching a tag
docker run --rm playwright-tests:local npx playwright test --grep @smoke
# Run only Chromium
docker run --rm playwright-tests:local npx playwright test --project=chromium
# Run a specific test file
docker run --rm playwright-tests:local npx playwright test tests/login.spec.tsPass environment variables with -e:
docker run --rm \
-e BASE_URL=https://staging.example.com \
-e TEST_USER=testuser \
-e TEST_PASSWORD=secret \
-v $(pwd)/playwright-report:/app/playwright-report \
playwright-tests:localMakefile or package.json script for the full docker run command so your team doesn't have to remember the volume mount flags. npm run test:docker is easier to document and harder to mistype than the full command.docker-compose for Local Test Runs
When your tests run against an application, you need both the app and the test container running at the same time with network access between them. docker-compose manages multi-container setups with a single file.
Here's a docker-compose.yml for a typical setup where Playwright tests run against a locally running web app:
# docker-compose.yml
version: '3.8'
services:
# Your application under test
app:
image: your-app:latest
ports:
- '3000:3000'
environment:
NODE_ENV: test
DATABASE_URL: postgres://user:pass@db:5432/testdb
depends_on:
db:
condition: service_healthy
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/health']
interval: 5s
timeout: 5s
retries: 10
# Database (if your app needs one)
db:
image: postgres:16
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: testdb
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U user -d testdb']
interval: 3s
timeout: 3s
retries: 15
# Playwright test container
tests:
build:
context: .
dockerfile: Dockerfile
environment:
BASE_URL: http://app:3000
depends_on:
app:
condition: service_healthy
volumes:
- ./playwright-report:/app/playwright-report
- ./test-results:/app/test-results
# Don't start tests automatically — run on demand
profiles:
- testingThe profiles: [testing] on the test service means it won't start when you run docker-compose up by itself (which would start the app and database). To run tests:
# Start the app and database
docker-compose up -d app db
# Run tests
docker-compose run --rm tests
# Or start everything and run tests in one command
docker-compose --profile testing run --rm tests
# Bring everything down
docker-compose downServices communicate using their service names as hostnames. The test container sets BASE_URL: http://app:3000, and Docker's internal DNS resolves app to the app container's IP address. Your playwright.config.ts reads process.env.BASE_URL, so locally it hits localhost:3000 and in the container it hits http://app:3000.
The depends_on with condition: service_healthy is important. Without it, Playwright would start before the app is ready to accept connections and every test would fail with a connection error. The healthcheck polls until the app responds, then Docker starts the dependent container.
Integrating Docker into GitHub Actions
GitHub Actions can use a Docker container as the execution environment for an entire job, or you can run docker build and docker run as steps. For Playwright specifically, using the container directly as the job environment is the cleanest approach.
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 60
# Use the Playwright image as the job container
container:
image: mcr.microsoft.com/playwright:v1.52.0-jammy
options: --user 1001
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run Playwright tests
run: npx playwright test
env:
BASE_URL: ${{ vars.BASE_URL }}
TEST_USER: ${{ secrets.TEST_USER }}
TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
- name: Upload test report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 14The container: block at the job level tells GitHub Actions to run every step inside the specified Docker container instead of directly on the Ubuntu runner. The browsers are already installed in the image, so you skip the npx playwright install step entirely.
--user 1001 sets the container user to match the GitHub Actions runner UID. Without this, file permission errors can occur when Actions tries to write artifacts.
For teams that also want to build and push their own test image to a registry:
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build test image
run: docker build -t playwright-tests:${{ github.sha }} .
- name: Run tests
run: |
docker run --rm \
-e BASE_URL=${{ vars.BASE_URL }} \
-e TEST_USER=${{ secrets.TEST_USER }} \
-e TEST_PASSWORD=${{ secrets.TEST_PASSWORD }} \
-v ${{ github.workspace }}/playwright-report:/app/playwright-report \
playwright-tests:${{ github.sha }}
- name: Upload test report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 14Using ${{ github.sha }} as the image tag ties the image to the exact commit, which makes it easy to correlate test runs with code versions if you're pushing to a registry.
Common Issues and How to Fix Them
Docker containers introduce a small set of problems that come up regularly when running Playwright. Here's what to expect and how to handle each.
File permission errors on reports. When Playwright writes to a volume-mounted directory, the files are owned by whatever user the container runs as (oftenroot). Your host user can't modify or delete them without sudo. The fix is to set the container user at run time:
docker run --rm \
--user $(id -u):$(id -g) \
-v $(pwd)/playwright-report:/app/playwright-report \
playwright-tests:localThis runs the container as your current host user, so the report files are owned by you. In CI, use --user 1001 as shown in the workflow above.
# Slow: any file change invalidates the npm ci cache
COPY . .
RUN npm ci
# Fast: only package file changes invalidate the npm ci cache
COPY package.json package-lock.json ./
RUN npm ci
COPY . .In CI, Docker layer caching requires explicit configuration. Add this to your workflow:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build test image
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: playwright-tests:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=maxThe cache-from: type=gha and cache-to: type=gha,mode=max lines use GitHub Actions cache storage for Docker layer caching. Without this, every CI run builds the image from scratch.
localhost refers to the container itself, not your host machine. If your app is running on your host at localhost:3000, the container can't reach it at that address. Use your host machine's Docker bridge IP (172.17.0.1 on Linux, host.docker.internal on Mac and Windows):
docker run --rm \
-e BASE_URL=http://host.docker.internal:3000 \
-v $(pwd)/playwright-report:/app/playwright-report \
playwright-tests:localWhen using docker-compose, this issue disappears because all services share a network and communicate by service name.
FAQ
Do I need Docker if I'm already using GitHub Actions?Not necessarily. GitHub Actions runners work well for Playwright without Docker: install Node, run playwright install --with-deps, and you're done. Docker becomes valuable when you want the same environment locally and in CI, when your tests run against a multi-service stack (app + database), or when onboarding new team members who need a zero-setup path to running tests.
Start with the official image as your FROM base. You get free updates to browser versions when you bump the tag, and the image is maintained by people who know Playwright's dependencies. Build on top of it only when you need project-specific additions, like custom fonts, a specific Node version, or pre-seeded test data.
Mount the test-results directory and enable video recording in your Playwright config for failed tests. After the run, the test-results/ folder on your host will contain videos and traces. Open the trace with npx playwright show-trace test-results/.../trace.zip to see exactly what happened.
Yes. Playwright's workers setting controls parallelism and works the same inside a container. The practical limit is the container's CPU count. For large suites, run multiple containers in parallel (via GitHub Actions matrix sharding) rather than maxing out workers inside a single container. It's easier to reason about and easier to scale.
This usually means there's a version mismatch between the image tag and your @playwright/test version. Check that mcr.microsoft.com/playwright:v1.52.0-jammy matches "@playwright/test": "1.52.0" in package.json. If the versions don't align, Playwright looks for browsers in a path that doesn't exist in the image.
.env file to the container?
Use --env-file with docker run:
docker run --rm \
--env-file .env.test \
-v $(pwd)/playwright-report:/app/playwright-report \
playwright-tests:localNever commit .env files to your repository. Add .env* to .gitignore and use CI secrets for sensitive values.