Back vallusby ExpandIt The browsers have to get there somehow
A portable external USB hard drive with its cable

Closed environments · offline install

The browsers have to get there somehow

Installing Playwright browsers on a machine with no route to the internet

Playwright · offline · browsers · what actually happens

Blue external USB hard drive 01.jpg - Coyau, CC BY-SA 3.0 (Wikimedia Commons)

Introduction: the command that assumes a network

npx playwright install is one line in every getting-started guide, and it hides a download of several hundred megabytes from a Microsoft CDN. On a laptop that is invisible. On a machine inside a segregated network it is the point where the whole setup stops.

What follows is a complete procedure for one machine: what to fetch on the connected side, how to move it, where to put it, and how to prove the browser actually starts. No product pitch, nothing you have to buy. If you only ever do this once, this is the page you needed.


1What playwright install actually does

Three things happen when you run it, and separating them is what makes the offline version possible:

  1. It reads the browser revisions pinned by the installed @playwright/test version. Every Playwright release is bound to specific Chromium, Firefox and WebKit builds.
  2. It downloads the matching archives from the CDN, by default to a per-user cache.
  3. It unpacks them and, with --with-deps, installs system libraries through the distribution package manager.

Two consequences follow immediately. The cache is version-specific, so a cache built for 1.47 will not satisfy 1.49. And it is platform-specific, so a cache built on macOS arm64 is useless on Linux x64. Both mistakes are common and both produce confusing errors on the far side.


2Where the browsers live

By default the cache goes to a per-user directory:

PlatformDefault location
Linux~/.cache/ms-playwright
macOS~/Library/Caches/ms-playwright
Windows%USERPROFILE%\AppData\Local\ms-playwright

Per-user is the wrong shape for a server. Set an explicit path on both machines and the rest of the procedure becomes symmetrical:

bash
export PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright

The value has to match on the connected machine and the isolated one. Playwright records nothing about the absolute path inside the archives, but your own scripts, containers and service definitions will refer to it, and a mismatch is the second most common failure after the version mismatch.


3On the connected machine

Match the environment you are shipping to. Same operating system family, same CPU architecture, same Playwright version. If the target is Linux x64 and you work on an Apple laptop, do this step in a container or on a build agent, not natively.

bash
mkdir -p /tmp/pw-export && cd /tmp/pw-export

# Pin the exact version you will run on the far side.
npm init -y >/dev/null
npm install --save-exact @playwright/test@1.47.0

export PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
npx playwright install chromium firefox webkit

Take only what you need. Most suites run on Chromium alone, and each engine is a substantial fraction of the transfer. If you are unsure, check playwright.config.ts for the projects that are actually enabled.

Now package the cache together with the evidence of what it is:

bash
cd /opt
tar czf /tmp/pw-export/ms-playwright-1.47.0-linux-x64.tar.gz ms-playwright

cd /tmp/pw-export
npx playwright --version              >  MANIFEST.txt
uname -sm                             >> MANIFEST.txt
ls "$PLAYWRIGHT_BROWSERS_PATH"        >> MANIFEST.txt
sha256sum ms-playwright-*.tar.gz > SHA256SUMS

The manifest matters more than it looks. Six months later, when a second archive appears on the same file share, the only thing distinguishing them is what you wrote down. List the revisions as well as the version: the person on the far side needs to know what the archive satisfies, not just what produced it.

System libraries

--with-deps installs distribution packages, and those come from your operating system repositories rather than from Playwright. Offline, they need the same treatment:

bash
# See what would be installed, without installing it
npx playwright install-deps --dry-run

Take the resulting package list to your distribution's offline tooling: a local mirror, or downloaded .deb/.rpm files with their dependencies. On a minimal server image this is usually where the real work is, not in the browser archives.

The container alternative

There is a second route, and for some environments it is the better one. Microsoft publishes an official image with the browsers and their system libraries already inside:

bash
docker pull mcr.microsoft.com/playwright:v1.47.0-jammy
docker save mcr.microsoft.com/playwright:v1.47.0-jammy \
  | gzip > playwright-1.47.0-jammy.tar.gz

That single artefact replaces both the browser cache and the system library problem, which is most of the work above. It is the right choice when there is an image registry on the far side and the transfer budget can absorb it.

It is the wrong choice when the transfer is the constraint. The image is several times larger than the browser cache alone, the browser versions are tied to the image tag, and the multi-version arrangement in section 7 does not apply - carrying four versions means carrying four images.


4On the isolated machine

The cache arrives as a file, but the machine still has to be persuaded not to reach for the network during ins
The cache arrives as a file, but the machine still has to be persuaded not to reach for the network during install.U.S. Coast Guard Aviation Maintenance Technician 2nd Class Joseph Sippel, right, lifts part of an engine cowling on an HC-130 Hercules aircraft for Avionics Electrical Technician 2nd Class Michael Mckinney while 130729-G-ZV557-792.jpg - PO3 David Weydert, public domain (Wikimedia Commons)

Two things have to be there before any of this runs, and neither comes from Playwright: Node.js itself, and the project's npm dependencies. npm ci on a machine with no route out fails immediately, because there is no registry to reach. Either the node_modules directory travels with the package, or the dependencies arrive as tarballs, or your organisation has an internal registry. That is a separate problem with a separate procedure, and it is covered in its own article in this series.

Assuming the dependencies are in place, install them without triggering a browser download:

bash
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
npm ci

That variable is often misunderstood. It does not make Playwright run without browsers. It only suppresses the download step during npm install, so that the install does not fail on a machine with no route out. The browsers still have to be present.

Unpack the cache to the same path you exported from:

bash
sha256sum -c SHA256SUMS
sudo tar xzf ms-playwright-1.47.0-linux-x64.tar.gz -C /opt --no-same-owner
export PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright

--no-same-owner matters more than it looks. Without it, tar restores the numeric user and group from the machine that built the archive. If that UID does not exist here, the files end up owned by a number nobody can read, and the result surfaces later as a permission error that looks like a completely different problem.

Make the path variable permanent for whatever runs the tests: the systemd unit, the container environment, the CI runner definition. A path that only exists in your interactive shell will work while you are testing and fail at three in the morning.


5Verifying, before you trust it

Three checks, in increasing order of confidence.

The resolution check. Ask Playwright what it would download. Every line should refer to a browser that is already present:

bash
npx playwright install --dry-run

The launch check. Starting a browser exercises the binary, its shared libraries and its sandbox, which is where missing system packages actually surface:

bash
node -e "require('@playwright/test').chromium.launch().then(b => b.close()).then(() => console.log('chromium ok'))"

The real check. Run one actual test against something reachable inside the network. A browser that launches can still fail to render, and a suite that passes locally can still time out on a page that never finishes loading because it waits for a font from a CDN.


6The failures you will actually hit

Executable doesn't exist at .../chrome-linux/chrome The path is wrong, or the version is. Compare the revision directory names under your cache with what npx playwright install --dry-run expects. Nine times out of ten the Playwright version on the two machines differs by a minor release.

error while loading shared libraries: libnss3.so The archives contain browsers, not system libraries. Go back to the install-deps list and get those packages onto the machine.

Chromium starts, then dies without a message Almost always /dev/shm. The default 64 MB in a container is not enough:

yaml
services:
  tests:
    shm_size: "2gb"     # or ipc: host

Permission denied when several users run tests A cache under one user's home directory, unpacked with restrictive permissions, or restored with an owner that does not exist here. This is what the explicit shared path and --no-same-owner avoid.

It worked yesterday and today it downloads again Something bumped @playwright/test, usually a lockfile that was not committed or a ^ range in package.json. Pin the exact version and commit the lock.


7Several versions in the same cache

The cost is the window, not the weight. Four versions cross on the same trip as one, and the rollback stops be
The cost is the window, not the weight. Four versions cross on the same trip as one, and the rollback stops being a request.110322-F-YC711-028 (5550420623).jpg - US Air Force from USA, public domain (Wikimedia Commons)

The cache is addressed by browser revision, not by Playwright version. A real directory looks like this:

/opt/ms-playwright/
  chromium-1155/            chromium_headless_shell-1200/
  chromium-1200/            chromium_headless_shell-1208/
  chromium-1208/            firefox-1471/   webkit-2123/
  chromium-1217/            firefox-1497/   webkit-2227/
  chromium-1223/            firefox-1522/   webkit-2248/

Five Chromium revisions, side by side. They coexist happily, but not by default, and the mechanism is worth understanding before you rely on it.

Playwright garbage-collects browsers it thinks nobody needs

Next to the revisions sits a .links directory. Every install writes a file there pointing at the playwright-core package that requested it:

/opt/ms-playwright/.links/
  18c303ab...  ->  /projects/suite-a/node_modules/playwright-core
  3ba23eb0...  ->  /projects/suite-b/node_modules/playwright-core
  7f0e6cf6...  ->  /usr/lib/python3.12/site-packages/playwright/driver/package

On every playwright install, Playwright reads that registry, works out which revisions are still required by an installation that exists on disk, and deletes the rest. So the five revisions above survive because there are five live projects asking for them, not because installs are additive.

Which means the obvious way to build a multi-version cache does not work:

bash
# WRONG: each install replaces node_modules, so the previous version is no longer
# registered, and its browsers are collected on the next run.
for v in 1.47.0 1.49.0; do
  npm install --save-exact @playwright/test@"$v"
  npx playwright install chromium
done

Run that and you end up with one revision, not two. Measured, not assumed:

after 1.47.0   chromium-1134  ffmpeg-1010
after 1.49.0   chromium-1148  chromium_headless_shell-1148  ffmpeg-1010

chromium-1134 is gone.

Turning the collection off

There is a switch, and it is an environment variable rather than a command-line flag:

bash
PLAYWRIGHT_SKIP_BROWSER_GC=1 npx playwright install chromium

With it set, the same loop keeps everything:

after 1.47.0   chromium-1134
after 1.49.0   chromium-1134  chromium-1148  chromium_headless_shell-1148

Two properties of that variable are worth knowing. It only suppresses the cleanup; the install itself behaves normally. And playwright uninstall deliberately ignores it, so removal always works even if the variable is set globally in your shell profile.

The alternative, if you would rather not depend on an environment variable, is to keep each version's project directory around, so every install stays registered in .links. That is what produces a cache like the one at the top of this section on a developer machine: nobody planned it, the projects simply still exist.

Why this matters behind an air gap

Projects migrate independently. Two suites on the same runner can sit on different Playwright versions, each resolving to its own revisions out of the same shared directory. Nobody has to be upgraded on somebody else's schedule.

Rollback stops being a transfer. If a new version misbehaves against the application under test, going back is a change to package.json and a reinstall from the local cache. Without the older revisions present, the same rollback means another trip through the customer's transfer process, which is measured in weeks.

One window covers several upgrades. Since the expensive part is the transfer and the approval, not the disk space, carrying three or four versions through a single window is almost free compared with doing it three or four times.

Building a multi-version cache

Same procedure as section 3, repeated against the same path, with the collection suppressed so each pass keeps what the previous one fetched:

bash
export PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
export PLAYWRIGHT_SKIP_BROWSER_GC=1

for v in 1.44.0 1.45.3 1.46.1 1.47.0; do
  npm install --save-exact @playwright/test@"$v"
  npx playwright install chromium        # add firefox webkit if you need them
done

ls "$PLAYWRIGHT_BROWSERS_PATH"           # verify every revision is still there

Check the directory listing rather than trusting the loop. If a revision is missing, the variable was not exported into the environment the install actually ran in, which is easy to get wrong inside a CI step or a container.

Then archive the whole directory once. The manifest should list every Playwright version the cache satisfies, not just the newest, because that list is what someone on the far side needs in order to know what they can install without asking for anything.

What it costs

Roughly 1 GB per version for all three engines, or a few hundred megabytes for Chromium alone. Ten versions of all three engines is something like 10 GB in the transfer, which is a deliberate trade rather than an obvious win: cheap if your constraint is the approval process, expensive if it is the physical media.

Chromium ships a separate headless shell (chromium_headless_shell-<revision>), so the number of directories is larger than the number of browsers you think you installed. And ffmpeg-<revision> appears once, shared, used for video recording.

Pruning, deliberately

If you suppressed the collection, nothing is removed automatically any more, so a long-lived cache grows. Prune it on the same cadence you upgrade, and only against a written record of which project uses which version:

bash
npx playwright install --list      # browsers across all installations, grouped by version
npx playwright uninstall           # revisions for the current installation only
npx playwright uninstall --all     # everything Playwright has installed at this path

Without --all, uninstall reports how many browsers remain because other installations still use them, which is the number you want before deciding anything is safe to delete.

--all is blunter than it looks in a shared environment: it removes revisions other projects may still be using. On a runner hosting several suites, prefer deleting specific revision directories against your register.


8Keeping it repeatable

For one machine, the procedure above is enough. The moment there are two, write down four things and keep them next to the archive: the Playwright version, the platform triple, the browsers included and the checksum. A tarball with no manifest is indistinguishable from any other tarball, and the failure it causes appears weeks later as an unexplained version mismatch.

If you find yourself repeating this monthly, you have outgrown the manual procedure and want an internal mirror instead: the same archives served from your own artifact repository, with PLAYWRIGHT_DOWNLOAD_HOST pointed at it. That is a different setup and a different article.

One thing this article deliberately leaves out is what happens when the official route exists but takes longer than the work allows. That is not a technical problem and it does not belong in a procedure, but it is the reason the procedure so often gets skipped.


9Conclusion

Nothing here is difficult. It is the kind of task that takes twenty minutes once you know the three moving parts, and half a day the first time because the failures point at the wrong things: a missing system library looks like a broken download, and a version mismatch looks like a corrupted archive.

The three parts, one more time:

  1. The cache is version- and platform-specific. Build it on a machine that matches the target, with the version pinned.
  2. PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD only suppresses the download. The browsers still have to arrive some other way.
  3. System libraries are a separate problem. install-deps tells you what is needed; getting those packages in is your distribution's problem, not Playwright's.

And one thing worth doing on the way in: carry more than one version. Revisions can coexist in the same cache, so the marginal cost is disk rather than another trip through the transfer process, which turns a rollback from a three-week request into a line in package.json. Just remember that coexistence is not the default: without PLAYWRIGHT_SKIP_BROWSER_GC=1, each install quietly collects the revisions no live installation still claims.

Download the offline copyOne HTML file with everything inside - opens with no internet at all.