ScreenshotNeo

BlogHow-to

How to Install Puppeteer in Docker for Website Screenshots

Install Puppeteer in Docker, launch Chrome safely, and capture reliable website screenshots with a reproducible Node.js setup.

By the ScreenshotNeo team1 October 20269 min read

Use the puppeteer package in a Debian-based Node.js image, install Chrome and its Linux libraries during the image build, run the browser as an unprivileged user, and write screenshots to a writable directory. The example below produces a full-page PNG from a URL.

Puppeteer normally downloads a compatible Chrome for Testing during installation. For Puppeteer versions starting with 21.6.0, the installation may also include a chrome-headless-shell binary. A Docker image still needs shared libraries, fonts, writable profile and cache directories, and a runtime that can reap browser child processes.

1. Create a minimal screenshot project

Create a project with a lockfile so Docker builds resolve the same dependency versions:

mkdir puppeteer-docker-screenshot
cd puppeteer-docker-screenshot
npm init -y
npm install puppeteer

Create capture.js:

const puppeteer = require('puppeteer');

const targetUrl = process.env.TARGET_URL || 'https://example.com';
const outputPath = process.env.OUTPUT_PATH || '/screenshots/page.png';

(async () => {
  const browser = await puppeteer.launch({
    // The image runs Chrome as pptruser, so a sandbox can remain enabled.
    headless: true,
    args: ['--disable-dev-shm-usage']
  });

  try {
    const page = await browser.newPage();
    await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
    await page.goto(targetUrl, {
      waitUntil: 'networkidle2',
      timeout: 60_000
    });
    await page.screenshot({
      path: outputPath,
      type: 'png',
      fullPage: true
    });
    console.log(`Saved ${outputPath}`);
  } finally {
    await browser.close();
  }
})().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

The --disable-dev-shm-usage flag places Chromium temporary shared-memory files outside Docker’s often-small /dev/shm. You can instead increase shared memory with docker run --shm-size=1g.

2. Build a Docker image with Chrome dependencies

This Dockerfile uses a Debian Bookworm Node image, installs common browser libraries and fonts, creates a non-root user, and explicitly runs Puppeteer’s browser installer. Package names can change with the base distribution; use the Puppeteer Docker guide and the project’s Dockerfile as the compatibility reference.

FROM node:22-bookworm

ENV NODE_ENV=production \
    XDG_CONFIG_HOME=/tmp/xdg-config \
    XDG_CACHE_HOME=/tmp/xdg-cache \
    PUPPETEER_CACHE_DIR=/tmp/puppeteer-cache

RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates \
    fonts-liberation \
    fonts-noto-color-emoji \
    libasound2 \
    libatk-bridge2.0-0 \
    libatk1.0-0 \
    libcups2 \
    libdbus-1-3 \
    libdrm2 \
    libgbm1 \
    libgtk-3-0 \
    libnspr4 \
    libnss3 \
    libu2f-udev \
    libvulkan1 \
    libx11-6 \
    libx11-xcb1 \
    libxcb1 \
    libxcomposite1 \
    libxdamage1 \
    libxext6 \
    libxfixes3 \
    libxkbcommon0 \
    libxrandr2 \
    xdg-utils \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
# Use this when install scripts were disabled by your package manager.
RUN npx puppeteer browsers install chrome

COPY capture.js ./
RUN useradd --create-home --shell /bin/bash pptruser \
    && mkdir -p /screenshots "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$PUPPETEER_CACHE_DIR" \
    && chown -R pptruser:pptruser /app /screenshots /tmp/xdg-config /tmp/xdg-cache /tmp/puppeteer-cache

USER pptruser

ENTRYPOINT ["node", "capture.js"]

Build and run it with Docker’s init process enabled:

docker build -t puppeteer-screenshot .
mkdir -p screenshots
docker run --init --rm \
  -e TARGET_URL=https://example.com \
  -e OUTPUT_PATH=/screenshots/example.png \
  -v "$PWD/screenshots:/screenshots" \
  puppeteer-screenshot

The output path is inside the container. Mounting ./screenshots makes the file available on the host. If your application returns the screenshot bytes directly, omit path and handle the returned Uint8Array instead.

3. Choose between puppeteer and puppeteer-core

Package Browser behavior Use it when
puppeteer Downloads a compatible browser during installation by default. You want the simplest self-contained image.
puppeteer-core Does not download Chrome. You manage a system browser, use a remote browser, or need explicit browser lifecycle control.

With puppeteer-core, configure the browser explicitly:

const puppeteer = require('puppeteer-core');
const browser = await puppeteer.launch({
  executablePath: process.env.CHROME_PATH,
  headless: true
});

You can also select a locally installed channel when supported by your environment. Keep the browser and Puppeteer versions compatible; a system Chrome update can otherwise change behavior independently of your application.

4. Configure screenshot output

page.screenshot() returns image bytes and accepts options for the file path, format, page extent, clipping, quality, and transparency.

Viewport versus full page

await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 });
await page.screenshot({ path: '/screenshots/viewport.webp', type: 'webp', quality: 85 });
await page.screenshot({ path: '/screenshots/full.png', fullPage: true });

fullPage: true captures the document’s complete scrollable height. Very tall pages consume more memory and can create large image files. For a fixed region, use clip:

await page.screenshot({
  path: '/screenshots/region.png',
  clip: { x: 0, y: 0, width: 800, height: 600 }
});

Element screenshots

const card = await page.waitForSelector('.pricing-card', { timeout: 15_000 });
await card.screenshot({ path: '/screenshots/pricing-card.png' });

Transparent backgrounds and formats

await page.screenshot({
  path: '/screenshots/transparent.png',
  omitBackground: true
});

PNG is the default. JPEG and WebP support a quality value from 0 to 100; quality does not apply to PNG. The format can be inferred from the path extension, or set with type.

5. Wait for the page you actually want to capture

Navigation completion is not the same as application readiness. Select a wait strategy based on the page:

  • waitUntil: 'domcontentloaded' returns after the initial HTML is parsed.
  • waitUntil: 'load' waits for the load event.
  • waitUntil: 'networkidle2' waits until there are at most two active network connections.
  • waitForSelector() is usually the most precise option for a known component.
  • waitForFunction() handles application-specific readiness flags.
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForSelector('[data-screenshot-ready="true"]', { timeout: 30_000 });
await page.evaluate(() => document.fonts.ready);
await new Promise((resolve) => setTimeout(resolve, 300));
await page.screenshot({ path: '/screenshots/ready.png', fullPage: true });

Lazy-loaded images may not exist until they enter the viewport. Scroll through the page before a full-page capture when necessary:

await page.evaluate(async () => {
  await new Promise((resolve) => {
    let y = 0;
    const step = 600;
    const timer = setInterval(() => {
      window.scrollBy(0, step);
      y += step;
      if (y >= document.body.scrollHeight) {
        clearInterval(timer);
        window.scrollTo(0, 0);
        resolve();
      }
    }, 100);
  });
});

6. Authentication, headers, cookies, and page state

Set request headers before navigation, or use HTTP authentication and cookies for protected pages:

await page.setExtraHTTPHeaders({ 'Accept-Language': 'en-US,en;q=0.9' });
await page.setCookie({
  name: 'session',
  value: process.env.SESSION_COOKIE,
  domain: 'example.com',
  path: '/',
  httpOnly: true,
  secure: true
});
await page.goto('https://example.com/account', { waitUntil: 'networkidle2' });

Do not put credentials in an image layer. Pass secrets at runtime through your deployment’s secret mechanism. For an authenticated proxy or HTTP basic authentication, configure the browser or page before calling goto.

7. Production Docker considerations

Run as a non-root user

Chrome’s sandbox is part of its security model. A dedicated unprivileged user avoids the common root-container workaround of disabling the sandbox. Do not add --no-sandbox by default; only consider it after understanding your container runtime and its security boundary.

Give Chrome writable directories

Chrome writes profile, configuration, and cache data. Read-only filesystems commonly cause launch failures. Set XDG_CONFIG_HOME and XDG_CACHE_HOME to writable temporary directories, set a writable userDataDir when needed, or mount volumes owned by the runtime user.

Reap child processes

Use docker run --init or an equivalent init process so exited Chrome children do not accumulate as zombies. Always close the browser in a finally block, including when navigation or screenshot creation fails.

Fonts and international pages

Missing fonts produce blank squares, changed line wrapping, or different screenshot dimensions. Install the font families required by the languages you render and keep the font set consistent between development and production.

Alpine Linux

Puppeteer’s documentation states that Chrome does not support Alpine out of the box. If you choose Alpine, verify Chromium dependencies and compatibility for the exact Alpine, Chromium, and Puppeteer versions. A Debian or Ubuntu-based image is usually the more direct path for a first deployment.

8. Common errors and fixes

Error or symptom Cause Fix
Could not find Chrome The package manager blocked the install script, or the browser cache was not copied into the runtime stage. Allow Puppeteer’s install script or run npx puppeteer browsers install chrome during the image build. For puppeteer-core, set executablePath or connect to a remote browser.
Browser exits immediately with missing .so files The base image lacks a Chrome shared library. Install dependencies for the selected distribution. Start with the current Puppeteer Docker documentation or official Dockerfile instead of copying an old package list.
Failed to move to new namespace or sandbox errors Chrome cannot use its sandbox under the current user or runtime policy. Run as a correctly configured non-root user and check container permissions. Treat --no-sandbox as a deliberate security decision, not a default fix.
EROFS, permission denied, or profile errors Chrome’s config, cache, profile, or output directory is read-only or owned by another user. Use writable XDG directories, a writable userDataDir, and an output directory owned by the runtime user.
Navigation timeout The page is slow, keeps connections open, or never reaches the selected wait condition. Set an appropriate timeout, use domcontentloaded plus an application readiness selector, and inspect the URL from inside the container.
Screenshot is blank or missing content Rendering occurred before fonts, data, animations, or lazy images were ready. Wait for a selector, document.fonts.ready, a readiness flag, or a short application-specific delay. Scroll before full-page capture for lazy content.
Output file is not on the host path points to the container filesystem. Mount a host directory at the output path, copy the file from the container, or return screenshot bytes from your service.
Container slowly fills with processes Chrome children are not being reaped or browsers are not closed. Use Docker init, close each browser in finally, and avoid launching one browser per URL when a controlled browser pool is suitable.

9. Performance, reliability, and cost

  • Build time and image size: the downloaded Linux browser is approximately 282 MB according to Puppeteer’s installation documentation; this is an estimate, not a guaranteed final image-layer size. Cache dependency and browser layers in CI.
  • Launch overhead: reuse a browser process for a batch of pages, while creating a fresh incognito context or page when isolation is required.
  • Concurrency: limit simultaneous pages according to available CPU and memory. Full-page screenshots and large sites require more memory than viewport captures.
  • Reliability: pin the Node image digest and lockfile when reproducibility matters, but update browser dependencies deliberately. Record the target URL, viewport, wait condition, and browser version with generated artifacts.
  • Network behavior: a container must be able to resolve DNS and reach the target site. Corporate proxies, TLS interception, bot checks, and geolocation can change the rendered result.
  • Storage: PNG preserves detail but is larger; WebP or JPEG can reduce storage when your downstream system accepts lossy output.
  • Cost: self-hosting costs the compute, memory, storage, and maintenance of the container and browser. Browser downloads also affect build bandwidth and cache storage.

10. Or skip the browser setup

ScreenshotNeo provides a website screenshot API and MCP server. One request returns a PNG, JPEG, WebP, or PDF, so you do not have to maintain Chrome dependencies in your application image.

cURL:

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

Python:

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Node.js:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const fs = require('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

See the ScreenshotNeo API documentation for request options. Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots.

Create a free ScreenshotNeo account and start with 1,000 screenshots a month at no charge.

11. FAQ

Does Puppeteer install Chrome automatically?

The puppeteer package normally downloads a compatible browser during installation. If install scripts are disabled, run npx puppeteer browsers install chrome during the Docker build.

Can I use a remote browser?

Yes. Use puppeteer-core and connect to the remote browser endpoint, or configure an explicit local executable path.

Why is my screenshot different in Docker?

Viewport size, device scale factor, fonts, timezone, locale, browser version, network access, and readiness timing all affect pixels. Keep these inputs consistent and wait for application content before capture.

Should I use the official Puppeteer Docker image?

It can be a convenient starting point, but image tags are volatile. Choose and pin a tag deliberately, or use the custom Dockerfile when you need control over the base image, fonts, dependencies, and update policy.

Where can I find the browser’s downloaded files?

Puppeteer documents $HOME/.cache/puppeteer as the default cache location since version 19.0.0. Set PUPPETEER_CACHE_DIR when you need a predictable writable path in Docker.