GitLab CI for Playwright: Complete Pipeline Guide
Your Playwright suite passes locally but you have no idea whether it works on every merge request, and “works on my machine” is not a release gate. This guide shows you how to build a fast, reliable Playwright GitLab CI pipeline in TypeScript that runs on every push, shards across parallel runners, caches browsers, and uploads HTML reports and traces you can actually open when a test fails. By the end you will have a production-ready .gitlab-ci.yml you can paste into any repository.
🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.
Contents
Why run Playwright on GitLab CI
GitLab CI gives you a YAML-defined pipeline that lives next to your code, free shared runners, and first-class artifact storage for reports and traces. For an end-to-end suite that is exactly what you want: deterministic Docker-based runs, parallelism through the parallel keyword, and a place to store the Playwright HTML report so any reviewer can inspect a failure without re-running anything. Compared with stitching together a bare shell script, a proper Playwright GitLab CI pipeline gives you caching, retry control, and merge-request integration out of the box.
The two things that make or break a CI run are the browser binaries and determinism. Playwright ships browsers separately from the npm package, so CI must either install them every run or cache them. And because CI machines are slower and noisier than your laptop, your config needs sensible retries, a single worker per shard, and tracing enabled on the first retry so you never debug blind.
Prerequisites and project layout
You need a GitLab repository (SaaS or self-managed) with shared or self-hosted runners that support the docker executor, plus a Playwright project using @playwright/test. A minimal layout looks like this:
playwright.config.ts— test configurationtests/— your spec filespackage.jsonwith@playwright/testpinned to a fixed version.gitlab-ci.yml— the pipeline definition
Pin the Playwright version in package.json and match the Docker image tag to it. Mismatched versions are the single most common cause of “browser not found” errors in CI.
A CI-aware playwright.config.ts
The config below behaves differently on CI than locally. It forbids accidentally committed test.only, retries flaky tests twice, runs a single worker per machine (GitLab handles parallelism for you), and produces a blob report so individual shards can later be merged into one HTML report.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
// Fail the build if test.only is committed.
forbidOnly: !!process.env.CI,
// Retry flaky tests on CI only.
retries: process.env.CI ? 2 : 0,
// One worker per runner; GitLab parallelism does the scaling.
workers: process.env.CI ? 1 : undefined,
// blob report merges cleanly across shards; HTML stays for local runs.
reporter: process.env.CI
? [['blob'], ['list']]
: [['html', { open: 'never' }], ['list']],
use: {
baseURL: process.env.BASE_URL ?? 'https://playwright.dev',
// Capture a trace the first time a test is retried.
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
The trace: 'on-first-retry' setting is the highest-value line in the file. It costs nothing on green runs and gives you a full timeline, DOM snapshots, and network log the moment a test is unstable enough to need a retry.
The minimal .gitlab-ci.yml
Start with the simplest pipeline that works. Use the official Playwright Docker image whose tag matches your installed version so the browsers are already present — no separate install step, no caching needed for the binaries.
stages:
- test
e2e:
stage: test
image: mcr.microsoft.com/playwright:v1.55.0-noble
variables:
# Browsers ship inside the image; avoid re-downloading.
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
script:
- npm ci
- npx playwright test
artifacts:
when: always
paths:
- playwright-report/
- blob-report/
expire_in: 7 days
Two details matter here. First, npm ci (not npm install) gives you a clean, lockfile-exact install every time. Second, artifacts.when: always ensures the report is uploaded even when the job fails — which is exactly when you need it. Without when: always, a red pipeline throws away the evidence.
Caching node_modules to speed up installs
The Docker image already contains the browsers, so the only thing worth caching is node_modules. Key the cache on the lockfile hash so it invalidates automatically when dependencies change.
e2e:
stage: test
image: mcr.microsoft.com/playwright:v1.55.0-noble
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
policy: pull-push
script:
- npm ci --prefer-offline --no-audit
- npx playwright test
artifacts:
when: always
paths:
- playwright-report/
- blob-report/
expire_in: 7 days
If you run your own GitLab Runner and prefer to mount a host volume for node_modules, you can skip GitLab’s cache entirely. But on shared runners the file-keyed cache above is the reliable, portable choice. Add --prefer-offline so npm uses cached packages when the cache is warm.
Sharding across parallel runners
One job running the whole suite serially is the slowest possible setup. Playwright supports --shard, and GitLab’s parallel keyword spins up N identical jobs with a CI_NODE_INDEX and CI_NODE_TOTAL for each. Wire them together and your suite runtime drops nearly linearly with the shard count.
stages:
- test
- report
e2e:
stage: test
image: mcr.microsoft.com/playwright:v1.55.0-noble
parallel: 4
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
script:
- npm ci --prefer-offline --no-audit
- npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
artifacts:
when: always
paths:
- blob-report/
expire_in: 1 day
Each of the four jobs runs roughly a quarter of the tests and produces its own blob-report/. The blob format is purpose-built for this: it is a self-contained, mergeable report fragment. In the next stage we collect all four fragments into a single browsable HTML report.
Merging shard reports into one HTML report
Add a report stage that depends on every shard, downloads their blob artifacts, and runs merge-reports to produce one combined HTML report. This is the piece most teams miss — without it you get four partial reports instead of one coherent view.
merge-reports:
stage: report
image: mcr.microsoft.com/playwright:v1.55.0-noble
needs:
- job: e2e
artifacts: true
when: always
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
script:
- npm ci --prefer-offline --no-audit
- npx playwright merge-reports --reporter=html ./blob-report
artifacts:
when: always
paths:
- playwright-report/
expire_in: 30 days
Because needs pulls artifacts from all four parallel e2e jobs, every blob fragment lands in ./blob-report before the merge runs. The when: always on both the job and its artifacts guarantees you still get a merged report even if some shards failed. After the pipeline finishes, browse the report from the job’s artifacts, or expose it through GitLab Pages for a stable URL.
Showing pass/fail in merge requests with JUnit
GitLab renders a test summary inside the merge request widget if you feed it a JUnit XML file. Add the JUnit reporter to your config and point GitLab’s artifacts:reports:junit at the output. Reviewers then see failing test names directly on the MR without opening any artifact.
// playwright.config.ts (reporter section)
reporter: process.env.CI
? [
['blob'],
['junit', { outputFile: 'results.xml' }],
['list'],
]
: [['html', { open: 'never' }]];
Then reference it from the job so GitLab parses the results:
artifacts:
when: always
reports:
junit: results.xml
paths:
- blob-report/
- results.xml
expire_in: 7 days
🚀 Level Up Your Playwright
From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.
Running only on merge requests and the default branch
You rarely want the full E2E suite firing on every branch push. Use rules to run on merge requests and on the default branch, and to skip duplicate “detached” pipelines. This keeps runner minutes focused on changes that are about to merge.
e2e:
stage: test
image: mcr.microsoft.com/playwright:v1.55.0-noble
rules:
# Run for merge requests.
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
# Run on the default branch (e.g. main).
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
script:
- npm ci --prefer-offline --no-audit
- npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
Choosing how to provide browsers: image vs. install
You have two valid strategies for getting browsers onto the runner. The official image is the path of least resistance; installing into a plain Node image gives you control at the cost of speed. This table summarizes the trade-offs so you can pick deliberately.
| Concern | Official Playwright image | Plain node image + install |
|---|---|---|
| Browsers | Pre-installed, version-matched | Installed at runtime via npx playwright install |
| OS dependencies | Already present | Need –with-deps or apt packages |
| Cold-start speed | Fast (no browser download) | Slower; download every run unless cached |
| Image size | Larger (~2 GB) | Smaller base, grows after install |
| Version drift risk | Low if tag matches package | Higher; easy to mismatch |
| Best for | Most teams, simplest setup | Custom base images, locked-down registries |
If you must use a custom Node image, install browsers and their OS dependencies in one step with npx playwright install --with-deps chromium, and cache the browser path with PLAYWRIGHT_BROWSERS_PATH set to a directory inside the project so GitLab’s cache can persist it between runs.
Putting it all together
Here is a complete pipeline combining everything above: sharded tests on the official image, node_modules caching, JUnit for the MR widget, blob artifacts, and a merge stage that builds a single 30-day HTML report. This is a solid baseline for a real Playwright GitLab CI pipeline.
stages:
- test
- report
variables:
PLAYWRIGHT_VERSION: v1.55.0-noble
.cache_template: &node_cache
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
e2e:
stage: test
image: mcr.microsoft.com/playwright:$PLAYWRIGHT_VERSION
parallel: 4
<<: *node_cache
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
script:
- npm ci --prefer-offline --no-audit
- npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
artifacts:
when: always
reports:
junit: results.xml
paths:
- blob-report/
- results.xml
expire_in: 1 day
merge-reports:
stage: report
image: mcr.microsoft.com/playwright:$PLAYWRIGHT_VERSION
<<: *node_cache
needs:
- job: e2e
artifacts: true
when: always
script:
- npm ci --prefer-offline --no-audit
- npx playwright merge-reports --reporter=html ./blob-report
artifacts:
when: always
paths:
- playwright-report/
expire_in: 30 days
Debugging failures from CI artifacts
When a shard fails, download the merged playwright-report artifact and open index.html locally, or run npx playwright show-trace trace.zip on the trace file the report links to. The trace viewer replays the run frame by frame with DOM snapshots, console output, and network activity — the same experience you get locally, reconstructed entirely from the CI artifact. Because the config above captures the trace on the first retry, the failing case is almost always already recorded.
A quick triage workflow: open the HTML report, find the red test, click into its trace, and scrub to the failing action. Ninety percent of CI flakiness turns out to be a missing auto-wait condition or an environment difference (timezone, viewport, locale) that the trace makes obvious in seconds.
Conclusion
A good Playwright GitLab CI pipeline is mostly about three disciplines: pin the browser version to a matching Docker image, shard the suite across parallel runners and merge the blob reports back into one, and always upload artifacts so a failure is debuggable from the trace viewer. Start with the minimal job, add caching, then sharding and the merge stage, and finally rules to control when it runs. Paste the complete pipeline above into your .gitlab-ci.yml, match the image tag to your installed Playwright version, and you have a fast, reproducible, fully observable end-to-end pipeline on every merge request.
FAQ
Why does GitLab CI say the browser executable does not exist?
This almost always means the Playwright npm version and the Docker image tag are out of sync. The browsers baked into mcr.microsoft.com/playwright:v1.55.0-noble only match Playwright 1.55. Pin @playwright/test to the same version in package.json and use the matching image tag. If you install into a plain Node image instead, run npx playwright install --with-deps so both browsers and their OS dependencies are present.
How do I make Playwright tests run in parallel on GitLab CI?
Combine GitLab’s parallel: N keyword with Playwright’s --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL. GitLab launches N identical jobs and injects the index and total as environment variables; Playwright slices the test list accordingly. Each job emits a blob report, and a follow-up stage runs npx playwright merge-reports --reporter=html to combine them into a single HTML report.
How do I view the Playwright HTML report and traces after a pipeline?
Configure the job’s artifacts.paths to include playwright-report/ with when: always so it uploads even on failure. After the pipeline runs, download the artifact from the job page and open index.html, or publish it via GitLab Pages for a permanent URL. For deep debugging, open the linked trace with npx playwright show-trace trace.zip to replay the run with DOM snapshots and network logs.
🎓 Master Playwright End to End
Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.
