Introduction: when copying archives stops scaling
Moving a browser cache by hand works for one machine. It keeps working for two. Somewhere around the fourth engineer asking where the current archive lives, and the second time somebody unpacks a cache built for a different Playwright version, it stops being a procedure and becomes a source of incidents.
The fix is not a better naming convention for tarballs. It is to serve the archives from the same place your organisation already serves packages, and let Playwright fetch them the way it normally would, just from your host instead of the vendor's.
This is one piece of a larger constraint, covered in the pillar article on running test automation where there is no route to the internet. Here the scope is deliberately narrow: the browser archives, for a team rather than a machine.
The specifics below - the path shapes, the retry behaviour, which environment variables the installer honours - were read from playwright-core 1.60.0, not from the documentation, which gives the download host three sentences. They are implementation details and can move between releases. npx playwright install --dry-run tells you what your own version does, and is the check worth repeating on every upgrade.
1What PLAYWRIGHT_DOWNLOAD_HOST changes
Playwright builds download URLs from a base host and a fixed path structure derived from the browser and its pinned revision. Point the base somewhere else and the installer behaves exactly as before:
export PLAYWRIGHT_DOWNLOAD_HOST=https://nexus.internal/playwright
npx playwright install chromiumTwo properties make this the right shape for a team. Nothing about your test code changes, so nobody needs to learn anything. And the installer unpacks the archive into the standard cache layout, so a machine that used the mirror ends up with the same directory structure as one that used the CDN.
Be precise about what that second property is not. The installer does not verify the archive against a published hash. It downloads, unzips, checks that the expected executable exists, and writes a marker file. There is no integrity check to inherit, so whatever assurance you want about the bytes in your mirror has to come from your own process: fetch over TLS from the vendor, checksum on the way in, and let the artifact repository hold the record. That matters more here than on the public CDN, because the mirror is a host inside your network that somebody can write to.
There are per-browser overrides too, useful when only one engine comes from elsewhere:
export PLAYWRIGHT_CHROMIUM_DOWNLOAD_HOST=https://nexus.internal/playwright
export PLAYWRIGHT_FIREFOX_DOWNLOAD_HOST=https://nexus.internal/playwright
export PLAYWRIGHT_WEBKIT_DOWNLOAD_HOST=https://nexus.internal/playwrightThose three do not cover everything the installer fetches. The variable is picked by matching the start of the component's name, so the Chromium one also covers chromium-headless-shell and the tip-of-tree builds, and the Firefox one covers firefox-beta. But the installer knows ten downloadable components, and three of them - ffmpeg, winldd and android - have no per-browser variable at all. They read the generic PLAYWRIGHT_DOWNLOAD_HOST and nothing else. Set only the three per-browser hosts and FFmpeg still goes to the vendor's CDN: harmless in an office, and in a closed network five attempts against a host that was never reachable.
One more property of all four variables, easy to miss when a mirror misbehaves. The environment is not the only place they are read from. Playwright falls back to npm_config_playwright_download_host, and then to npm_package_config_playwright_download_host, so the value can arrive from an .npmrc or from a config block in package.json. A machine can be pointed at a mirror with no export anywhere in sight, which is worth knowing before you spend an afternoon reading the wrong env.
2The layout the installer expects
This is where most attempts fail. The mirror is not a directory of files you named yourself; it has to mirror the structure the installer constructs. The reliable way to discover it is to ask:
npx playwright install --dry-runEach entry prints the install location and the full URL that would be fetched. Take those URLs, strip the host, and you have the paths your mirror must serve. Do this once per Playwright version you support, and script it rather than transcribing by hand.
Two things about that output decide whether the script works.
Most browsers print more than one URL. Firefox, WebKit and FFmpeg each list a primary and two fallbacks, and the fallbacks are the same archive on different hosts. Two of the three collapse to the same path once the host is stripped, so a naive grep fetches the same file twice and writes it over itself, while the third lands somewhere else entirely. Take the first URL per entry, or deduplicate on the destination path:
# Fetch on a connected machine, preserving the path structure.
# "Download url:" only - the fallback lines are the same archive elsewhere.
npx playwright install --dry-run \
| awk '/^ *Download url:/ {print $3}' \
| while read -r url; do
path="${url#https://*/}"
mkdir -p "staging/$(dirname "$path")"
[ -f "staging/$path" ] || curl -sSL "$url" -o "staging/$path"
doneChromium does not follow the same path shape as the rest. Firefox, WebKit and FFmpeg sit under builds/<name>/<revision>/, keyed by Playwright's own revision number. Chromium now ships as Chrome for Testing and sits under builds/cft/<browser-version>/, keyed by the Chrome version. Your mirror has to serve both shapes, and a script that assumes one of them will silently produce a mirror that works for two engines out of three.
Upload staging/ into a raw repository in Nexus or a generic repository in Artifactory, and set PLAYWRIGHT_DOWNLOAD_HOST to its base URL. A plain static file server works just as well; the artifact repository buys you authentication, retention and an audit trail rather than any special capability.
The other model: a proxy repository
Everything above describes a mirror you fill yourself. The cheaper arrangement, and the one most teams reach for first, is a remote repository pointing at https://cdn.playwright.dev, caching each archive the first time somebody asks for it. Nexus calls it a proxy repository, Artifactory a remote repository, and the setup is a URL in a form.
It costs almost nothing to run: a new Playwright version needs no action from anybody, because the first engineer to install it populates the cache for everyone else. You still get the retention, the authentication and the audit trail. If the question is only "stop every laptop pulling three hundred megabytes from the public internet", this is the answer and the rest of this article is more machinery than you need.
The catch is the one that decides it for a closed environment: the mirror itself needs a route to the vendor. In a network with a genuine air gap there is no such route, and a proxy repository has nothing to proxy. That is the point at which you go back to filling it yourself, and why the manual procedure is the one described here in full.
3Versioning: the decision that actually matters
A mirror that holds one version is a tarball with extra steps. The value appears when it holds several, because that is what lets different projects move at different speeds.
Keep every version you have a project on, plus the one you are migrating to. Delete nothing that a pipeline still references, and keep a file next to the tree recording which internal projects use which version. That file is what makes it safe to prune, and without it nobody ever dares to.
The rule that prevents most incidents: the mirror is append-only. Never replace the contents of a revision directory in place. If an archive is wrong, publish a new version and migrate projects to it. Silently changing bytes under a path that machines have already cached produces failures that are close to undiagnosable.
One thing the mirror does not solve, and it catches people who assume it does: holding four versions on the server does not mean a machine ends up with four versions in its cache. Playwright collects browser revisions that no live installation still claims, so an engineer who installs those four in sequence keeps only the last one. The earlier article in this series, on installing browsers with no route to the internet, covers that mechanism and the environment variable that suppresses it.
4Feeding it
Whoever runs the mirror needs a repeatable way to add a version. In practice it is a short job on a connected build agent, run when a project wants to upgrade:
- Install the target
@playwright/testversion in a scratch directory. - Run the dry-run capture from section 2 to fetch the archives.
- Record a checksum of each archive as you fetch it, and keep it with the register.
--dry-runprints locations and URLs, not hashes, so there is nothing published to compare against: the number you write down is your own, and its value is that it detects a change in your mirror later, not that it proves anything about the vendor. - Upload under the same path structure.
- Record the version, the date and the requesting project in the register.
Two details worth building in from the start. Fetch for every platform your fleet runs, not just the one the build agent happens to be: a mirror that only serves Linux x64 will fail the first time somebody runs tests on an arm64 machine, and the error will point at the mirror rather than at the omission. And keep the fetch job's output, so that when a download fails months later you can tell whether the archive was ever there.
5What breaks when Playwright updates
A mirror is not a thing you build once. It is a thing that stops working, quietly, on a minor version bump, and the failure never says "your mirror is out of date". Three mechanisms are worth knowing before you depend on one, and all three are visible in the installer's own source rather than in any release note.
Setting the download host removes every fallback
Out of the box the installer carries three hosts for most browsers: a primary CDN, a second CDN, and the storage bucket directly. --dry-run prints them as Download fallback 1 and Download fallback 2. It retries five times, rotating through that list, so a single host having a bad afternoon is invisible to everyone.
The moment PLAYWRIGHT_DOWNLOAD_HOST is set, that list is replaced by exactly one entry: yours. The five retries remain, and all five go to the same place. You have not added a mirror in front of the vendor's redundancy; you have swapped the vendor's redundancy for a single host of your own.
That is a fair trade in a closed network, where the vendor was never reachable anyway. It is a poor one in an office network where somebody set the variable to save bandwidth and inadvertently made a Nexus instance a hard dependency of every CI job.
The path shape is not stable across versions
Chromium moved to Chrome for Testing and its archives moved with it: a different path prefix, and a key that is the Chrome version rather than Playwright's revision number. Any mirror built before that move served paths nobody asked for any more, and the symptom was a 404 from an internal host on a version of Playwright that had worked the week before.
Nothing prevents that happening again. The archives are an implementation detail of the installer, not a published interface, and they change when the browser vendors change.
The consequence: what to actually do
Treat the mirror as version-scoped, not evergreen. Before a project upgrades Playwright, run the dry-run capture for the new version on a connected machine and compare the paths against what the mirror already serves. It takes a minute and it turns a broken CI pipeline into a task in the upgrade ticket.
The check is one command and a diff:
# On a connected machine, against the version you are about to move to
npx playwright install --dry-run | awk '/^ *Download url:/ {print $3}' \
| sed 's|^https://[^/]*/||' | sort > wanted.txt
# Against what your mirror actually has
curl -s https://nexus.internal/playwright/manifest.txt | sort > have.txt
diff wanted.txt have.txtKeep that manifest deliberately. A mirror that cannot answer "what do you hold" turns every upgrade into an archaeology exercise against a directory listing.
6What it costs
Be honest with whoever approves this, because underselling the cost is how mirrors end up unmaintained:
| Initial setup | Half a day, most of it spent discovering the path structure |
| Adding a version | 20 to 30 minutes, mostly waiting for downloads |
| Cadence | Playwright releases roughly monthly; you will not follow every one |
| Storage | Around 1 GB per version for all three engines and one platform |
| The real cost | One named owner. Without one it rots and everyone reverts to tarballs |
Storage is never the constraint. The constraint is that somebody has to notice when a project needs a version that is not there yet, and that somebody has to be a person rather than a policy.
7Where the mirror ends
A browser mirror solves exactly one problem: getting engine binaries onto machines that cannot reach the CDN. It does not solve npm packages, system libraries, or container base images. Teams regularly discover this halfway through, when npm ci fails on a machine where the browsers are now perfectly available.
If you are building this because your whole pipeline runs without internet access, the browser mirror is one of four things you need, alongside a package registry proxy, an image registry and a way to get distribution packages in. Plan for all four or the first one will look like a failure.
8Conclusion
The mirror is not a clever trick. It is the same archives, on your own host, in the layout the installer already expects. What makes it work over time is not the serving, which is trivial, but three habits:
- Discover the layout, do not guess it.
--dry-runprints exactly what will be requested; script the capture from that output. - Append, never replace. A revision directory whose contents change is a failure nobody will trace.
- Name an owner. Everything else here is a one-off; keeping the mirror current is not.