ScreenshotNeo

BlogHow-to

How to Run Puppeteer in Docker

Run Puppeteer reliably in Docker with the official image, a secure custom Dockerfile, sandboxing, writable storage, cloud guidance, and fixes for common Chrome errors.

By the ScreenshotNeo team29 September 202610 min read

How to Run Puppeteer in Docker

Short answer: use Puppeteer’s maintained Docker image when you need the fastest path to a working browser. It includes Chrome for Testing, the required Linux dependencies, and a matching Puppeteer installation. Run the container with an init process and --cap-add=SYS_ADMIN so Chrome can use its sandbox:

docker run -i --init --cap-add=SYS_ADMIN --rm ghcr.io/puppeteer/puppeteer:latest node -e "$(cat path/to/script.js)"

The official image is the lowest-maintenance option. If you build your own image, use a supported Debian or Ubuntu-style Node base, install Chrome’s libraries and fonts, run as a non-root user, keep browser profile directories writable, and leave the sandbox enabled. Avoid --no-sandbox unless every page opened by the browser is fully trusted. The Puppeteer Docker guide and troubleshooting guide document the underlying requirements.

1. Run the official Puppeteer image

Create a small script named shot.js:

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({
    headless: 'new',
    args: ['--disable-dev-shm-usage']
  });

  try {
    const page = await browser.newPage();
    await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
    await page.goto('https://example.com', {
      waitUntil: 'networkidle2',
      timeout: 60_000
    });
    await page.screenshot({ path: '/tmp/example.png', fullPage: true });
    console.log('saved /tmp/example.png');
  } finally {
    await browser.close();
  }
})();

Run it with the maintained image:

docker run -i --init --cap-add=SYS_ADMIN --rm \
  -v "$PWD:/work" -w /work \
  ghcr.io/puppeteer/puppeteer:latest \
  node shot.js

The volume makes the output available on the host. --init supplies an init process that reaps child processes, which helps prevent zombie Chrome processes during repeated jobs. --cap-add=SYS_ADMIN gives Chrome the capability expected by the image while retaining sandboxed execution.

Use a container entrypoint for an HTTP service

For a service, keep one browser process per request only when traffic is low. For higher throughput, launch one browser and create a new page or browser context per job. Always close pages and contexts in a finally block.

const express = require('express');
const puppeteer = require('puppeteer');

const app = express();
let browser;

async function getBrowser() {
  if (!browser || !browser.connected) {
    browser = await puppeteer.launch({
      headless: 'new',
      args: ['--disable-dev-shm-usage']
    });
  }
  return browser;
}

app.get('/screenshot', async (req, res) => {
  const target = req.query.url;
  if (!target || !/^https?:\/\//i.test(target)) {
    return res.status(400).send('url must start with http:// or https://');
  }

  let page;
  try {
    page = await (await getBrowser()).newPage();
    await page.setViewport({ width: 1365, height: 768 });
    await page.goto(target, { waitUntil: 'networkidle2', timeout: 60_000 });
    const png = await page.screenshot({ type: 'png', fullPage: true });
    res.type('png').send(png);
  } catch (error) {
    console.error(error);
    res.status(502).send('capture failed');
  } finally {
    if (page) await page.close().catch(() => {});
  }
});

app.listen(3000, '0.0.0.0', () => console.log('listening on 3000'));

Build and run the service with the same image, or copy the application into a custom image as shown below. A production service should also validate allowed destinations to prevent SSRF, set request and navigation timeouts, limit concurrent pages, and expose health checks.

2. Build a secure custom image

A custom image is useful when you need your own application, pinned versions, extra fonts, or a smaller operational surface. Start from a supported Debian-style Node image. The maintained Puppeteer Dockerfile currently uses a pinned Node 24 Bookworm base, sets LANG=en_US.UTF-8, defines a non-root PPTRUSER_UID, and installs the libraries and fonts Chrome for Testing needs.

A reliable container gives Chrome its dependencies, sandbox, and writable runtime paths.
A reliable container gives Chrome its dependencies, sandbox, and writable runtime paths.
FROM node:24-bookworm

ENV LANG=en_US.UTF-8 \
    PPTRUSER_UID=10001 \
    PUPPETEER_CACHE_DIR=/home/pptruser/.cache/puppeteer

RUN apt-get update && apt-get install -y --no-install-recommends \
      ca-certificates \
      fonts-liberation \
      fonts-noto-color-emoji \
      dumb-init \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

RUN useradd --create-home --uid ${PPTRUSER_UID} --shell /usr/sbin/nologin pptruser \
    && mkdir -p /home/pptruser/.cache/puppeteer /home/pptruser/.config \
    && chown -R pptruser:pptruser /app /home/pptruser

USER pptruser
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["node", "server.js"]

Install Puppeteer in package.json and allow its install script to download the browser:

{
  "private": true,
  "dependencies": {
    "express": "^5.0.0",
    "puppeteer": "^24.0.0"
  }
}

Build and run:

docker build -t my-puppeteer-service .
docker run --rm --init --cap-add=SYS_ADMIN -p 3000:3000 my-puppeteer-service

If your package manager blocks lifecycle scripts, Puppeteer may not download Chrome. The later symptom is usually Could not find Chrome (ver. ...). Check install logs and the Puppeteer cache before adding launch flags. If you intentionally install Chrome or Chromium separately, set executablePath to that binary and keep its version compatible with Puppeteer.

3. Configure sandboxing, users, and writable paths

Chrome’s Linux sandbox is a security boundary around untrusted web content. A non-root container user plus a working sandbox is the preferred design. The Puppeteer troubleshooting guide says Chrome can crash with No usable sandbox! when no suitable sandbox exists, and strongly discourages running without one.

Use --no-sandbox only for a tightly controlled workload where every URL and downloaded resource is trusted. It removes a protection layer; it is not a general fix for Docker permissions.

Chrome writes configuration, cache, crash-reporting, and profile data while starting. A read-only root filesystem therefore needs writable temporary paths:

ENV XDG_CONFIG_HOME=/tmp/.chromium \
    XDG_CACHE_HOME=/tmp/.chromium

# In application code, if needed:
const browser = await puppeteer.launch({
  userDataDir: '/tmp/.puppeteer-profile'
});

Alternatively mount writable volumes and ensure the runtime user owns them. Give /dev/shm enough space for your page complexity, or use --disable-dev-shm-usage to make Chrome use /tmp. The latter can be slower, so measure it with your workload.

4. Control navigation and capture behavior

Most Docker failures are actually page-lifecycle problems. Pick a navigation condition that matches the site:

Browser automation must wait for the right page state before saving the image.
Browser automation must wait for the right page state before saving the image.
  • domcontentloaded returns after the HTML is parsed and is useful for fast, mostly static pages.
  • load waits for subresources that participate in the load event.
  • networkidle2 waits until there are at most two active connections, but analytics, ads, and live applications can keep a page busy.

For applications that render after navigation, wait for a selector or a bounded delay:

await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForSelector('#app-ready', { timeout: 30_000 });
await page.screenshot({ path: 'result.webp', type: 'webp', quality: 85 });

For a full-page image, use fullPage: true. For one component, locate it and capture the element:

const card = await page.$('.pricing-card');
if (!card) throw new Error('pricing card not found');
await card.screenshot({ path: 'card.png' });

Set a deterministic viewport, timezone, locale, and user agent when pixel consistency matters. Install fonts for every language you render; missing glyphs otherwise appear as empty boxes.

5. Docker and cloud runtime considerations

Alpine

Chrome does not support Alpine out of the box. Alpine requires compatible system packages and a Puppeteer/browser version match. The troubleshooting guidance calls out timeout issues with Chromium in Alpine 3.20 and describes Alpine 3.19 as a workaround for that specific issue. Treat Alpine as a compatibility project and validate the exact image before production. Debian Bookworm is the lower-friction baseline.

Google Cloud Run

Launch Puppeteer before sending the HTTP response, or enable always-on CPU. Cloud Run can disable CPU after a response, so a background browser launch can appear to take minutes. The default Cloud Run Node runtime also lacks the system packages required by headless Chrome; deploy a custom container.

Lambda and other serverless platforms

AWS Lambda is possible, but it needs its own runtime and packaging validation. Check binary size, writable /tmp storage, architecture, cold-start behavior, and the exact Chrome build before committing to it.

6. Performance and reliability checklist

  1. Reuse a browser process and create isolated pages or contexts per request.
  2. Cap concurrency so memory use cannot grow without bound.
  3. Set navigation, selector, and overall job deadlines.
  4. Close pages in finally blocks and restart a browser that is disconnected.
  5. Use --init or dumb-init to reap child processes.
  6. Pin Node, Puppeteer, and the browser image in production; upgrade them together.
  7. Cache static assets or use a controlled network policy only when it does not change the page you need to capture.
  8. Record the URL, browser version, viewport, wait condition, and failure reason for every job.
  9. Install the fonts your pages require and test right-to-left and emoji rendering.
  10. Use a writable profile and cache path even when the container root is read-only.

Image size, startup time, and memory depend on the browser build, fonts, page content, and concurrency. The research sources provide configuration guidance rather than universal benchmarks, so measure with representative pages. A warm browser usually avoids repeated launch overhead, while a fresh browser per request provides stronger isolation at higher startup cost.

7. Troubleshooting common errors

Error Cause Fix
No usable sandbox! The container lacks a usable Chrome sandbox. Run as a non-root user, use the required capability such as --cap-add=SYS_ADMIN, and verify the image setup. Do not immediately add --no-sandbox.
Could not find Chrome (ver. ...) Install scripts were blocked, the cache is missing, or the executable path is wrong. Inspect package-manager logs and PUPPETEER_CACHE_DIR. Allow the browser download or set a correct executablePath.
chrome_crashpad_handler: --database is required Chrome cannot write crash-reporting or profile data. Set writable XDG_CONFIG_HOME and XDG_CACHE_HOME, and use a writable userDataDir.
Zombie Chrome processes No init process reaps children. Run Docker with --init or use dumb-init as the entrypoint.
Blank squares instead of characters Required fonts are absent. Install fonts for the languages, symbols, and emoji in your pages.
Navigation timeout The page never reaches the selected wait condition, or the network is slow. Use a suitable condition, wait for a specific selector, block known nonessential requests, and keep a bounded timeout.
Browser crashes under load Too many pages, low shared memory, or leaked contexts. Limit concurrency, close pages, increase shared memory, or use --disable-dev-shm-usage as a fallback.
Works locally but fails in Cloud Run CPU is disabled after the response or runtime libraries are missing. Launch before responding or enable always-on CPU, and deploy a custom image with Chrome dependencies.

8. Calling a local capture service

Once your container exposes /screenshot, any HTTP client can consume it. With cURL:

curl --fail --output page.png \
  --get 'http://localhost:3000/screenshot' \
  --data-urlencode 'url=https://example.com'

Python:

import requests

r = requests.get(
    'http://localhost:3000/screenshot',
    params={'url': 'https://example.com'},
    timeout=90,
)
r.raise_for_status()
with open('page.png', 'wb') as output:
    output.write(r.content)

Node.js:

const q = new URLSearchParams({ url: 'https://example.com' });
const res = await fetch(`http://localhost:3000/screenshot?${q}`);
if (!res.ok) throw new Error(`capture failed: ${res.status}`);
const data = Buffer.from(await res.arrayBuffer());
require('fs').writeFileSync('page.png', data);

Or skip the browser setup

If your goal is a reliable screenshot endpoint rather than maintaining Chrome in Docker, ScreenshotNeo returns a PNG, JPEG, WebP, or PDF from one GET request. See the ScreenshotNeo API docs for all parameters.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' }); const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

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 response headers identify the page verdict and billing status. ScreenshotNeo also provides an MCP server so Claude, Cursor, and other MCP clients can call take_screenshot, get_page_info, and capture_pdf. 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.

9. Cost and operational trade-offs

Self-hosting Puppeteer costs the compute, storage, bandwidth, and engineering time required to maintain browser versions, dependencies, fonts, sandbox permissions, and concurrency controls. A warm browser reduces launch work but needs lifecycle monitoring. Isolated browsers improve fault containment but increase startup and memory use. Keep these costs visible when comparing a container to an API.

ScreenshotNeo’s plans are Free (1,000 shots/month), Starter ($5 for 3,000), Growth ($15 for 15,000), Pro ($39 for 60,000), Scale ($99 for 250,000), and Business ($249 for 1,000,000); yearly billing gives two months free. Every feature is included on every plan, including full-page and element capture, device presets, custom viewport and retina scale, PDFs, custom CSS and JavaScript, clicks and waits, request blocking, headers, cookies, user agents, timezone and geolocation, transparent backgrounds, resizing, TTL caching, signed links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, usage reporting, and an OpenAPI specification.

FAQ

Do I have to use --no-sandbox in Docker?

No. The preferred setup is a non-root user with Chrome’s sandbox and the container capability required by the image. Use --no-sandbox only for fully trusted content when you accept the security trade-off.

Should I choose Alpine for a smaller image?

Only after validating the exact Chrome and Puppeteer combination. Alpine needs extra compatibility work; Debian Bookworm is the simpler baseline.

Why does a read-only container fail at startup?

Chrome needs writable configuration, cache, crash-reporting, and profile directories. Point XDG paths and userDataDir to writable storage or mount owned volumes.

Is one browser per request the safest pattern?

It provides isolation but adds startup cost. For sustained traffic, reuse a browser and isolate jobs with pages or contexts while enforcing concurrency and cleanup.

What is the quickest way to produce screenshots without maintaining Chrome?

Use ScreenshotNeo’s single GET endpoint or MCP tools; it handles browser infrastructure and removes common consent and overlay elements before capture.