TL;DR
- Running Puppeteer in Docker involves shipping Chrome with its system libraries, sandbox permissions, and shared-memory settings inside your container image.
- Two DIY routes exist: the official
ghcr.io/puppeteer/puppeteerimage, or installing Chrome into your own base image. - Five failure modes cause almost every "works locally, breaks in Docker" report: shared memory, the sandbox, zombie processes, version drift, and leaked browsers.
- A dedicated browser container removes all five from your application image.
Puppeteer sometimes works fine out of the box on a laptop and then breaks in a container because Chrome's system dependencies, sandbox, and shared-memory needs don't come along automatically.
In this guide, you'll build both DIY Docker setups, fix the five crashes that break them, and then move Chrome into its own container so your application image stays clean.
Why run Puppeteer in Docker?
Puppeteer bundles a Chromium download with npm install, which makes it feel self-contained – right up until you deploy. That Chromium build expects dozens of system libraries the average server image doesn't have, and a bare node:slim base or a CI runner won't launch it at all. The result is the classic gap where a script runs on your machine but throws launch errors everywhere else.
Docker closes that gap by packaging Chrome, its libraries, and your code into one image that behaves the same on every host:
- Predictable launches – The exact Chrome build and every library it needs ship together, so no more missing dependency errors on deploy.
- Consistent CI – Runners pull the image instead of installing Chrome on each job.
- Isolation – Chrome's memory use and crashes stay inside the container, and resource limits apply to the whole unit.
The catch is that Chrome doesn't behave in a container the way it does on a desktop, which is where most of the pain in this guide comes from. The two DIY routes below get you a working image, and the failure modes after them are what you'll hit in production.
Option 1: the official Puppeteer image
The Puppeteer team publishes an image with a compatible Chrome and all its system libraries already installed:
FROM ghcr.io/puppeteer/puppeteer:25.5.0
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["node", "script.js"]
Match the image tag to the Puppeteer version in your package.json – ghcr.io/puppeteer/puppeteer:25.5.0 bundles the Chrome build that Puppeteer 25.5.0 was tested against. Browserless applies the same rule to its own images, and mismatches show up as connection failures or protocol errors.
Each Puppeteer release is tested against one Chrome build, and if you mix versions you are more likely to encounter "browser was not found" errors at runtime. The image runs as a non-root pptruser, which is what Chrome's sandbox needs on the inside.
On the host side, you still have to grant the container permission to create user namespaces – on modern Linux hosts that means docker run --cap-add=SYS_ADMIN or a seccomp profile that allows clone. Without it, Chrome exits with No usable sandbox!.
Option 2: install Chrome in your own image
If you're building on your own base image, you have to install Chrome and its dependency list yourself:
FROM node:22-slim
RUN apt-get update && apt-get install -y wget gnupg \
&& wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/google.gpg] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google.list \
&& apt-get update && apt-get install -y google-chrome-stable fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
ENV PUPPETEER_SKIP_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# Chrome refuses to launch as root, so give the container a non-root user
RUN groupadd -r pptruser && useradd -rm -g pptruser -G audio,video pptruser \
&& chown -R pptruser:pptruser /app
USER pptruser
CMD ["node", "script.js"]
PUPPETEER_SKIP_DOWNLOAD stops npm from downloading a second Chromium you won't use, and PUPPETEER_EXECUTABLE_PATH points Puppeteer at the system Chrome.
This setup works, but you now own the dependency list, and it changes as Chrome does.
The crashes everyone faces
Here are five failure modes that account for many of the times Puppeteer works locally but not in Docker:
- Shared memory. Docker gives containers 64MB of
/dev/shm, but Chrome wants far more. Tabs crash under any real load. Fix it with--shm-size=2gon the run command (shm_size: "2g"in Compose).--disable-dev-shm-usageis the flag you'll see in old Stack Overflow answers – it works by pushing Chrome onto/tmp, which trades crashes for slower page loads, so treat it as a last resort when you can't set--shm-size. - The sandbox. Two things break the sandbox. Running Chrome as root is refused outright (
Running as root without --no-sandbox is not supported), so your image needs a non-rootUSER. Separately, the sandbox needs permission to create user namespaces, which Docker's default profile withholds – hence--cap-add=SYS_ADMINor a custom seccomp profile. - Zombie processes. Chrome spawns helper processes that never get reaped when your Node app is PID 1. Run the container with
--initso something is there to clean up. - Version drift. The Chrome inside your image and the Puppeteer version in
package.jsonhave to move together. Every upgrade is an image rebuild. - Leaked browsers. Every
puppeteer.launch()starts a full Chrome. If a script throws beforebrowser.close()runs, that Chrome stays alive holding its memory, and the container's RSS climbs until the OOM killer takes it. Wrap the browser lifecycle intry/finallysoclose()runs on the error path too. If you move to a browser container, this is handled for you: Browserless enforces aTIMEOUT(30 seconds by default) and reaps the session when it expires – but if you setTIMEOUT=-1to allow long-running jobs, you're back to owning the cleanup yourself.
Option 3: run Chrome in its own container
All the above problems can be traced back to the same decision: putting a browser inside your application image. Move the browser into its own container, and they become that container's configuration, not your app's. The other approach is to run a dedicated browser container and connect to it over WebSocket.
The open-source Browserless image ships Chromium preconfigured for exactly this, with queueing, health checks, and a debugger UI included:
docker run --rm -p 3000:3000 \
-e "TOKEN=your-secure-token" \
-e "CONCURRENT=10" \
--shm-size=2g \
ghcr.io/browserless/chromium
Your code changes by one call: puppeteer.launch() becomes puppeteer.connect().
import puppeteer from "puppeteer-core";
const browser = await puppeteer.connect({
browserWSEndpoint: "ws://localhost:3000?token=your-secure-token",
});
const page = await browser.newPage();
await page.goto("https://example.com");
console.log(await page.title());
await browser.close();
Switch to puppeteer-core, which skips the bundled browser download entirely.
With Browserless, your app is just a lightweight Node.js image with none of the browser baggage. All that Chrome configuration lives on the Browserless side instead. And upgrading Chrome is as simple as pointing at a newer version of the Browserless container ("pulling a new tag") – your app doesn't need to be touched.
Wired together in Docker Compose – the current image already binds to 0.0.0.0, so it accepts connections from your app container without any extra host configuration:
services:
app:
build: .
environment:
- BROWSER_WS=ws://browserless:3000?token=your-secure-token
depends_on:
browserless:
condition: service_healthy
browserless:
image: ghcr.io/browserless/chromium
environment:
- TOKEN=your-secure-token
- CONCURRENT=10
shm_size: "2g"
healthcheck:
test:
[
"CMD",
"curl",
"-f",
"http://localhost:3000/pressure?token=your-secure-token",
]
interval: 5s
retries: 10
Inside Compose, your app should read the endpoint from that BROWSER_WS variable rather than hardcoding localhost, because the browser container is reachable at its service name (browserless), not localhost.
To verify the browser container is healthy, hit its REST API directly:
docker compose exec browserless curl -X POST "http://localhost:3000/chromium/content?token=your-secure-token" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com"}'
Scaling beyond one container
A dedicated browser container gives you the levers that a bundled Chrome doesn't.
CONCURRENT caps parallel sessions and QUEUED buffers the overflow – set it to roughly 1.5–2× CONCURRENT, because at QUEUED=0 anything that arrives while all slots are busy is rejected with a 429. /pressure reports queue depth and running sessions, which is where you point your health checks and autoscaler.
Need more capacity? Add browser containers behind a load balancer and leave your app deployment alone. The NGINX load balancing guide shows the full setup.
When you'd rather not operate browser infrastructure at all, the same puppeteer.connect() call points at Browserless cloud:
const browser = await puppeteer.connect({
browserWSEndpoint: `wss://production-sfo.browserless.io?token=${TOKEN}`,
});
That adds managed scaling, stealth mode, and CAPTCHA solving with no Docker to maintain. For teams that need browsers inside their own network boundary, the self-hosted Enterprise image runs the same platform on your hardware.
Start with a free Browserless account, or pull ghcr.io/browserless/chromium and try the container route yourself.