ScreenshotNeo

BlogHow-to

How to Take Chromium Screenshots in Docker Without a Display

Capture Chromium screenshots in Docker with headless Chrome—no X11 or Xvfb required. Includes CLI and Puppeteer setup, output handling, security, and fixes for common failures.

By the ScreenshotNeo team4 October 20268 min read

Yes: run Chromium in Headless mode inside the container. For an ordinary screenshot, you do not need X11, Xvfb, or a physical display. The shortest path is chrome --headless --screenshot --window-size=1280,900 https://example.com. Chromium writes screenshot.png to the process’s current working directory, so choose a writable directory that is mounted or otherwise accessible if you need the host to retrieve the file.

This guide covers a one-off command and a Puppeteer workflow for scripted captures. Use the CLI for a simple URL capture; use Puppeteer when you need navigation logic, interactions, readiness checks, or control over the browser lifecycle.

1. Capture a screenshot with the Chromium CLI

Install a Chrome or Chromium build in the image, then run the headless screenshot command. The exact executable name varies by distribution and installation; it may be chrome, chromium, or another packaged name.

mkdir -p /output
cd /output
chrome --headless --screenshot --window-size=1280,900 https://example.com
ls -l screenshot.png

The screenshot is saved in the browser process’s current working directory. In Docker, mount that directory so the file persists after the container exits:

docker run --rm \
  --mount type=bind,src="$PWD/output",dst=/output \
  --workdir /output \
  chromium-image \
  chrome --headless --screenshot --window-size=1280,900 https://example.com

Replace chromium-image with your image name. Create the host-side output directory first and ensure the container user can write to it. The documented Chrome CLI example uses --window-size=412,892; choose dimensions that match the viewport you want to capture. This controls the viewport, not a promise that an arbitrarily long page will be captured in full.

Bound the wait for a page to load

For slow pages, add a timeout in milliseconds. It caps how long Chrome waits, including when the page remains loading; it does not guarantee that a site’s asynchronous application content is ready.

chrome --headless \
  --screenshot \
  --window-size=1280,900 \
  --timeout=15000 \
  https://example.com

For pages whose changes are driven by timers, --virtual-time-budget=MS advances virtual time so timer-based scripts can run before capture:

chrome --headless \
  --screenshot \
  --window-size=1280,900 \
  --virtual-time-budget=5000 \
  https://example.com

Neither option knows when your app-specific state is correct. If a page fetches data after navigation, waits for fonts, or reveals lazy content after scrolling, use application-specific readiness checks or a scripted browser flow.

2. Use Puppeteer for scripted captures

Puppeteer gives your code control over navigation, waiting, and the browser process. Its Docker guide describes an image that bundles Chrome for Testing, its required dependencies, and a preinstalled Puppeteer version. The example image is ghcr.io/puppeteer/puppeteer:latest; for repeatable builds, use a deliberate version tag and keep the Puppeteer package, browser, and image versions aligned instead of depending on a moving latest tag.

Example Node.js capture script, intended to run in an environment with the matching Puppeteer package and browser installed:

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({ headless: true });
  try {
    const page = await browser.newPage({
      viewport: { width: 1280, height: 900 },
    });
    await page.goto('https://example.com', {
      waitUntil: 'networkidle2',
      timeout: 30000,
    });
    await page.screenshot({ path: '/output/screenshot.png' });
  } finally {
    await browser.close();
  }
})().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Mount /output to a host directory and make it writable by the container user. Pick a readiness condition appropriate to the site: network idle can be unsuitable for pages with long-lived connections or continuous background requests. A selector that appears when the specific content is ready may be a better signal.

Puppeteer’s Docker guide documents using Docker’s --init and --cap-add=SYS_ADMIN for its example image; the guide says the image runs Chrome in sandbox mode and requires that capability. Follow the image’s current documentation for its exact run command and version. The Puppeteer Docker documentation also recommends an init process or a custom entrypoint to manage browser child processes.

3. Container settings that matter

Keep the browser sandbox enabled

Keep Chrome’s sandbox enabled where possible. Puppeteer strongly discourages --no-sandbox and says it should only be considered when the opened content is absolutely trusted. Configure the container runtime and permissions for sandboxed Chrome instead of treating sandbox removal as the default fix.

Provide writable runtime directories

Chrome writes profile, configuration, and cache files during startup. A read-only container can fail even when your screenshot output directory is writable. Provide writable paths for its runtime data; Puppeteer documents XDG_CONFIG_HOME, XDG_CACHE_HOME, and an explicit userDataDir as options.

mkdir -p /tmp/chrome-config /tmp/chrome-cache /tmp/chrome-profile
XDG_CONFIG_HOME=/tmp/chrome-config \
XDG_CACHE_HOME=/tmp/chrome-cache \
chrome --headless \
  --user-data-dir=/tmp/chrome-profile \
  --screenshot \
  --window-size=1280,900 \
  https://example.com

Ensure the chosen temporary paths are writable by the user running Chrome. For concurrent browser processes, give each one its own profile directory to avoid profile contention.

Check OS packages and fonts

Headless removes the visible-window requirement, but it still needs the browser’s shared libraries, fonts, and compatible operating-system packages. Puppeteer’s troubleshooting guide says Chrome does not support Alpine out of the box and advises checking dependencies and browser/Puppeteer compatibility. Verify the requirements for your exact distribution, CPU architecture, and browser version; dependency lists can differ.

Use current Headless mode

Use --headless for current Chrome. Chromium documents that a separate Headless shell became available through Chrome for Testing starting at M118. From M132, the old Headless shell functionality was removed from the Chrome binary; --headless=old has no effect. If you specifically need that legacy shell, migrate to chrome-headless-shell and check the documentation for the browser version you use.

4. Choose between CLI and Puppeteer

Need Use What to plan for
One URL and a basic viewport screenshot Chrome CLI Executable name, bounded wait if needed, and writable output location
Navigation steps, interactions, or app-specific readiness Puppeteer Matching package and browser versions, sandbox permissions, and browser lifecycle
Repeatable container builds Either, with pinned versions Pin the browser and OS image; validate in the same runtime used in production
Artifact must survive container exit Either Write to a mounted directory and check ownership and file existence

Headless rendering does not guarantee identical output across browser versions, fonts, graphics environments, or page timing. Pin versions and validate captures in the production runtime. This is operational guidance based on documented browser and dependency version sensitivity, not a performance benchmark.

5. Troubleshooting

Symptom Likely cause Fix
Chrome complains that it cannot open a display The executable is not running in headless mode, or a wrapper is invoking a different browser command. Run the intended Chrome/Chromium executable with --headless; inspect the command and browser version. A display server is not required for a normal headless screenshot.
No screenshot appears on the host Chrome writes to its current working directory, which may be inside the container rather than the mounted directory. Set --workdir to the mount destination or configure an output path supported by your installed Chrome build. Confirm the file exists in the container and that the host directory is mounted.
Permission denied writing output or profile files The output mount or Chrome config/cache/profile directories are not writable by the container user. Adjust ownership/permissions or point runtime paths to writable locations; check the browser’s effective user.
Chrome exits with missing library or startup errors Required OS dependencies are absent or the browser does not match the distribution or architecture. Use a compatible base image and install the dependencies for that exact browser build. Check Puppeteer’s troubleshooting guidance, especially for Alpine.
Browser sandbox initialization fails The container runtime lacks the permissions or setup required by the sandbox. Use the documented container configuration for the chosen Puppeteer image/runtime, including its required capability and init setup. Keep sandboxing enabled where possible.
Screenshot is blank, incomplete, or missing late content Capture happened before the app finished loading, content is lazy-loaded, or the selected viewport does not show the expected region. Use a suitable timeout or virtual time for timer-driven content; for app state, wait for the relevant selector or condition in Puppeteer. Check viewport dimensions and page behavior.
--headless=old does not change behavior Old Headless was removed from the Chrome binary starting at M132. Use current --headless, or use the separately distributed chrome-headless-shell if legacy shell behavior is required.
Captures differ between local and production Browser versions, fonts, OS packages, graphics environment, or timing differ. Pin the browser and image versions, align dependencies, and validate in the production container.

Or skip the browser setup

If you need screenshots in an application but do not want to package Chromium and its dependencies, ScreenshotNeo is a website screenshot API and MCP server for developers. Its [docs](https://screenshotneo.com/docs/) describe the API; one GET request can return a PNG, JPEG, WebP, or PDF. For a direct screenshot:

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

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
    timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://example.com',
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await require('node:fs/promises').writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

ScreenshotNeo removes cookie banners, popups, and chat widgets before capture; bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots. See the ScreenshotNeo documentation and sign up free for 1,000 screenshots a month, with no card.

6. Performance, reliability, and cost

No performance comparison is available here, so choose based on control and operational fit rather than an assumed speed advantage. With a self-hosted browser, account for the container image, browser dependencies, startup and cleanup, writable storage, and the maintenance work of keeping browser and Puppeteer versions compatible. A pinned image makes builds more repeatable, while validating the same image in production helps catch environment-specific rendering changes.

For reliability, use an init process, close browser instances in cleanup paths, bound navigation waits, and make readiness checks match the page. Keep output artifacts on a mounted volume if they must outlive the container. For cost, the dossier provides no benchmark or per-capture cost for a self-hosted setup; calculate infrastructure and maintenance costs from your own deployment. ScreenshotNeo’s published plan prices are Free for 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, and every feature is on every plan.

FAQ

Do I need Xvfb for Chromium screenshots in Docker?

No. For a normal screenshot, use Chrome’s Headless mode; Xvfb is a virtual display and is unnecessary for this workflow.

Where does the CLI screenshot go?

By default, Chrome writes screenshot.png to the process’s current working directory. Use a mounted, writable directory and set it as the container working directory, or use an output-path option supported by your installed build.

Does --timeout wait for my single-page app to finish rendering?

It bounds the wait; it does not identify your app’s ready state. Use a page-specific readiness check for asynchronous content.

Can I use Alpine Linux?

Puppeteer’s troubleshooting documentation says Chrome does not support Alpine out of the box. Choose a compatible base image or verify a supported dependency setup for your exact browser build.

Official references