Puppeteer in Docker: A Tested Dockerfile with Fonts
A non-root Puppeteer Dockerfile with Chromium, CJK and emoji fonts and dumb-init, plus the sandbox error it hits, measured image sizes and launch times.
To run Puppeteer in Docker, start from a Debian Node image, install a Chromium build and the fonts your pages need, run as a non-root user under an init process, and decide how to handle Chrome's sandbox. This is the Dockerfile we built and ran for this guide:
# syntax=docker/dockerfile:1 FROM node:22-bookworm-slim # Chromium from Debian (amd64 and arm64), fonts for Latin, CJK and emoji, # and dumb-init to reap Chrome's child processes. RUN apt-get update \ && apt-get install -y --no-install-recommends \ chromium \ fonts-liberation fonts-noto-core fonts-noto-cjk fonts-noto-color-emoji \ dumb-init ca-certificates \ && rm -rf /var/lib/apt/lists/* ENV CHROME_PATH=/usr/bin/chromium \ NODE_ENV=production WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY shot.mjs ./ # The node image ships a non-root "node" user (uid 1000). USER node ENTRYPOINT ["dumb-init", "--"] CMD ["node", "shot.mjs"]
It builds, but the first docker run fails. The section on the sandbox explains why, and which of the two fixes to choose. This page is the container chapter of our Puppeteer screenshot guide; the recipes for viewports, waits and formats are there.
Tested on 2026-10-18 with Docker 29.8.2 (OrbStack) on macOS 27.0.1, Apple M1 Pro, so the images are
linux/arm64. Base imagenode:22-bookworm-slim(Node 22.23.3 inside), Debian'schromium155.0.8059.79-1~deb12u1,[email protected]. A second image uses[email protected]with its own Chrome for Testing. All sizes and times below come from these builds; timings are from three to five runs each.
The script inside the image
shot.mjs takes a URL and an output path, captures one screenshot, and prints how long each step took. It uses puppeteer-core, because the browser comes from apt and Puppeteer must not download a second one.
// shot.mjs import puppeteer from 'puppeteer-core'; const url = process.argv[2] ?? 'https://example.com'; const out = process.argv[3] ?? '/tmp/shot.png'; const fullPage = process.env.FULL_PAGE === '1'; const t0 = performance.now(); const browser = await puppeteer.launch({ executablePath: process.env.CHROME_PATH ?? '/usr/bin/chromium', // Docker's default seccomp profile blocks the namespaces Chrome's sandbox // needs. Opt out explicitly, only inside a container you control. args: process.env.CHROME_NO_SANDBOX === '1' ? ['--no-sandbox'] : [], }); const tLaunch = performance.now(); try { const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: Number(process.env.DPR ?? 1), }); await page.goto(url, { waitUntil: 'load', timeout: 30_000 }); const buf = await page.screenshot({ path: out, fullPage }); console.log(JSON.stringify({ chrome: await browser.version(), launchMs: Math.round(tLaunch - t0), gotoAndShotMs: Math.round(performance.now() - tLaunch), bytes: buf.length, })); } finally { await browser.close(); }
package.json pins one dependency and sets ESM:
{ "name": "shot-worker", "private": true, "type": "module", "dependencies": { "puppeteer-core": "25.14.0" } }
Build and run:
npm install --package-lock-only docker build -t shot-worker . mkdir -p shots && chmod 777 shots docker run --rm -v "$PWD/shots:/out" shot-worker node shot.mjs https://example.com /out/example.png
The chmod is there because the container writes as uid 1000, which may not own the host directory.
Why each line is there
node:22-bookworm-slim. Debian, not Alpine. Alpine uses musl, and its Chromium package follows a different release train with different flags. On Debian, apt-get install chromium pulls every shared library Chromium needs, which is the part people usually get wrong when they write the dependency list by hand.
chromium from apt. Debian builds it for amd64 and arm64, so the same Dockerfile works on an Apple-silicon laptop and an x86 server. The catch is version drift: Debian's 155.0.8059.79 matched Puppeteer 25.14.0's expected Chrome on the day we tested, but that is not guaranteed. When apt moves ahead of or behind your Puppeteer, the Chrome DevTools Protocol can differ in small ways. Pin both, and rebuild when either moves.
The fonts. These four packages are what made our multilingual test render. Details and the measured cost are in the fonts section.
dumb-init. Chrome starts a tree of helper processes. If Node runs as PID 1 and Chrome's main process dies, those helpers are re-parented to PID 1, and Node never reaps them. dumb-init runs as PID 1 and reaps them. docker run --init does the same job without a package; the headless Chrome production failures post shows the zombies piling up without it.
USER node. The official Node images include a node user with uid 1000. Running Chrome as root also fails, with a different message (Running as root without --no-sandbox is not supported), and gives a page exploit root inside the container.
npm ci --omit=dev before COPY shot.mjs. The dependency layer stays cached when only your code changes.
The sandbox error, and the two real fixes
With the Dockerfile above, every run failed in under a second with this message (shortened):
Error: Failed to launch the browser process: Code: 1 stderr: ... zygote_host_impl_linux.cc:130] No usable sandbox! If this is a Debian system, please install the chromium-sandbox package to solve this problem.
Chrome isolates each renderer with Linux namespaces. Docker's default seccomp profile blocks the system calls a non-root process needs to create them. We tried three changes:
| What we changed | Result |
|---|---|
Added the chromium-sandbox package (setuid helper) |
Still failed: Failed to move to new namespace ... errno = Operation not permitted |
docker run --security-opt seccomp=unconfined |
Worked, with the sandbox on |
--no-sandbox passed to Chrome (CHROME_NO_SANDBOX=1) |
Worked |
So the options are:
- Keep Chrome's sandbox and relax seccomp just enough. Do not use
seccomp=unconfinedin production; it turns off a whole layer of protection for everything in the container. Write or adopt a seccomp profile that is Docker's default plus the namespace calls Chrome needs, and pass it with--security-opt seccomp=chrome.json. This is the stronger setup when you render URLs from untrusted users. We confirmed only that unconfined works; we did not test a specific custom profile for this page. - Turn off Chrome's sandbox and rely on the container. Pass
--no-sandbox, run as non-root, drop capabilities (--cap-drop=ALL), set a memory limit, and do not mount anything sensitive. This is what most hosted setups do. The risk is concrete: a renderer exploit runs with the container's rights, not inside Chrome's inner sandbox.
The script above makes the choice explicit with CHROME_NO_SANDBOX=1 instead of hard-coding the flag, so a reviewer can see it in the deployment config. The Chromium sandbox glossary entry has the background.
Fonts: what they fix and what they cost
We rendered one line, Hello 你好 こんにちは 안녕 🎉✅, at 48 px in two images. With only fonts-liberation, the Latin text drew and every CJK character and both emoji were empty boxes. The output PNG was 7 KB, because there was almost nothing to draw. With the four packages in the Dockerfile, every glyph drew, including the color emoji, and the PNG was 23 KB.
The fonts are the second-largest part of the image:
| Item | Size |
|---|---|
/usr/lib/chromium |
354 MB on disk |
/usr/share/fonts (OpenType 89 MB + TrueType 55 MB) |
144 MB on disk |
/app/node_modules (puppeteer-core and dependencies) |
32 MB on disk |
| Whole image with Noto fonts | 466 MB compressed, 1.64 GB on disk |
| Whole image without the three Noto packages | 361 MB compressed, 1.39 GB on disk |
If you render only Latin-script pages that you control, drop fonts-noto-cjk and keep the emoji font. If you render arbitrary URLs, keep all of them; a box in place of a product name is a worse outcome than 105 MB of registry storage. To make text look the same in Docker as on a Mac, see consistent fonts in headless rendering.
Measured launch and capture times
Each run is a fresh docker run with CHROME_NO_SANDBOX=1, capturing example.com at 1280 x 800. "Launch" is puppeteer.launch(); "goto + shot" includes the network round trip to example.com.
| Run | Launch | goto + shot | Whole docker run |
|---|---|---|---|
| 1 | 289 ms | 415 ms | 1.98 s |
| 2 | 267 ms | 638 ms | 2.32 s |
| 3 | 352 ms | 717 ms | 2.90 s |
| 4 | 378 ms | 476 ms | 2.42 s |
| 5 | 333 ms | 702 ms | 2.71 s |
Three things to note. First, Chrome's launch was not the slow part: container start and Node start took about 1.3 to 1.8 s of each run. Second, the sandbox did not change launch time much: with seccomp=unconfined and the sandbox on, three runs launched in 299 to 412 ms. Third, these are cold numbers. A service that keeps one browser running pays the launch once; the timeouts and memory leaks guide shows how to keep that browser healthy.
The same PNG of example.com was 73,698 bytes in the container and 81,234 bytes on the Mac. The Mac was in Dark appearance, so headless Chrome picked a dark background there; the container defaulted to light. Set prefers-color-scheme explicitly when you compare images across machines.
Alternative: Puppeteer's own Chrome for Testing
Since Chrome 153, Chrome for Testing ships linux-arm64 builds as well as linux64, and @puppeteer/browsers 25.14 picks the right one. If you want the exact Chrome your Puppeteer version was tested against, install it in the image instead of Debian's package:
# syntax=docker/dockerfile:1 FROM node:22-bookworm-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ fonts-liberation fonts-noto-core fonts-noto-cjk fonts-noto-color-emoji \ dumb-init ca-certificates \ && rm -rf /var/lib/apt/lists/* ENV PUPPETEER_CACHE_DIR=/opt/puppeteer NODE_ENV=production WORKDIR /app COPY package.json package-lock.json ./ # Skip the postinstall download; install Chrome and its system libraries explicitly. RUN PUPPETEER_SKIP_DOWNLOAD=1 npm ci --omit=dev \ && npx puppeteer browsers install chrome --install-deps \ && rm -rf /var/lib/apt/lists/* COPY shot.mjs ./ USER node ENTRYPOINT ["dumb-init", "--"] CMD ["node", "shot.mjs"]
Here package.json depends on puppeteer (not puppeteer-core), and shot.mjs drops executablePath. PUPPETEER_CACHE_DIR moves the browser out of root's home directory, so the node user can read it. --install-deps runs apt-get for Chrome's shared libraries, which is why this line runs before USER node. The sandbox error and both fixes are the same as above.
Debian chromium |
Chrome for Testing | |
|---|---|---|
| Version | Whatever bookworm ships today | Exactly what your Puppeteer expects |
| Security updates | apt in a rebuild |
Bump Puppeteer and rebuild |
| Architectures | amd64, arm64 | linux64, linux-arm64 (from Chrome 153) |
| npm package | puppeteer-core |
puppeteer |
BUILDRESULT
Running it as a service
The image above runs one capture and exits, which is right for a batch job or a CI step. For an HTTP service, keep the same base and change three things:
- Keep one browser per container and one browser context per request; close the context in
finally. - Give the container a memory limit that fits your worst page. In our Linux measurements a single full-page capture of a 20,000 px page at scale 2 took the Chrome tree from about 320 MB to 794 MB.
- Add a health check that opens and closes a page, and restart the container when it fails.
docker run -d --init --name shot-worker \ --memory 2g --cap-drop ALL \ -e CHROME_NO_SANDBOX=1 \ shot-worker node server.mjs
server.mjs is your own code; the timeouts and memory leaks guide has a tested pool class with per-job deadlines and browser recycling you can put inside it. --init is redundant with dumb-init in the image, and harmless.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
No usable sandbox! |
Docker's seccomp profile blocks user namespaces | Custom seccomp profile, or --no-sandbox in a hardened container |
Failed to move to new namespace ... Operation not permitted |
Same cause, with chromium-sandbox installed |
Same fix; the setuid helper does not get around seccomp |
Running as root without --no-sandbox is not supported |
Container runs as root | USER node |
Could not find Chrome (ver. ...) |
puppeteer installed, but the browser download was skipped or went to root's cache |
Use puppeteer-core with executablePath, or set PUPPETEER_CACHE_DIR and install Chrome in the image |
| Boxes instead of CJK text or emoji | No fonts with those glyphs | fonts-noto-cjk, fonts-noto-color-emoji |
EACCES writing the screenshot |
Volume owned by another uid | Write to /tmp and copy, or fix ownership on the host |
Zombie chrome processes |
No init process as PID 1 | dumb-init or docker run --init |
| Different image on the Mac and in Docker | OS color scheme, fonts | Emulate prefers-color-scheme; use the same fonts |
| Container killed with exit code 137 | Memory limit reached | Raise the limit, lower concurrency, or avoid scale 2 on long pages |
Or skip the image
A screenshot container is a small service with a large surface: a browser to patch, fonts to keep, a sandbox decision to defend, memory limits to tune. If you would rather not own it, RenderScreenshot runs the browser and returns the image. The capture this page tested, as one request with the Node.js SDK:
import { writeFile } from 'node:fs/promises'; import { Client, TakeOptions } from 'renderscreenshot'; const client = new Client(process.env.RS_API_KEY); const image = await client.take( TakeOptions.url('https://example.com').width(1280).height(800).format('png'), ); await writeFile('example.png', image);
The Puppeteer migration guide maps your existing calls to parameters, and the emoji rendering guide covers the fonts question on our side. Sign up for 150 free credits a month and run the same URL through both setups.
Related guides
- Puppeteer screenshot recipes, tested on Puppeteer 25
- Puppeteer on AWS Lambda, when a container is more than you need
- Headless Chrome production failures, reproduced
- Puppeteer timeouts and memory leaks
Related in this topic
- Puppeteer Screenshots: Tested Recipes for Puppeteer 25 Every Puppeteer screenshot recipe re-run on Puppeteer 25.14 and Chrome 155: viewport, full page, element, clip, waits, formats, auth, blocking and fonts.
- Puppeteer on AWS Lambda with @sparticuz/chromium Run Puppeteer on AWS Lambda with @sparticuz/chromium: version matching, a container-image handler tested in the Lambda emulator, and warm reuse.
- Puppeteer Full Page Screenshot: Limits, Lazy Images, Fixes Puppeteer full page screenshot guide: the 16,384 px limit we measured, captureBeyondViewport, lazy-load scrolling, sticky headers and a tested script.
- Hide Cookie Banners and Chat Widgets in Puppeteer Remove cookie banners, consent iframes, shadow-DOM dialogs and chat widgets from Puppeteer screenshots: a tested module and what each method misses.
- Puppeteer HTML to PDF: page.pdf() Options Tested Generate PDFs with Puppeteer page.pdf(): paper size, margins, header and footer templates, page breaks and print CSS, with measured sizes and timings.
- Puppeteer Screenshot Timeouts and Memory Leaks: A Debugging Guide A comprehensive debugging guide for Puppeteer screenshot issues — covering navigation timeouts, memory leaks, zombie Chrome processes, and production error handling patterns.