· Tutorials · By Simon Chiu

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 image node:22-bookworm-slim (Node 22.23.3 inside), Debian's chromium 155.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:

  1. Keep Chrome's sandbox and relax seccomp just enough. Do not use seccomp=unconfined in 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.
  2. 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 in this topic