How to Run Chrome Headless Shell in Docker
Run Chrome Headless Shell in Docker with Puppeteer, secure sandboxing, pinned versions, CLI captures, troubleshooting, and production guidance.
Short answer: use an image that contains Chrome for Testing and its Linux dependencies, run the container with an init process, preserve Chrome’s sandbox, and select the standalone shell with Puppeteer’s headless: 'shell' option. The maintained starting point for Node projects is ghcr.io/puppeteer/puppeteer. Since Chrome 132, regular Chrome’s --headless flag selects Unified Headless; the former implementation is distributed as chrome-headless-shell. Choose Shell for a leaner workload when its reduced feature set is sufficient, and Unified Headless when tests need behavior closer to the full Chrome browser. See Chrome’s Headless mode documentation and Puppeteer’s Docker guide.
1. Headless Shell versus Unified Headless
| Question | Headless Shell | Unified Headless |
|---|---|---|
| Executable | Standalone chrome-headless-shell |
Regular Chrome binary with --headless |
| Fidelity | Reduced feature set; not identical to full Chrome | Closer to full Chrome behavior |
| Weight | Lighter, with fewer dependencies | More complete browser implementation |
| Best fit | Rendering, screenshots, DOM extraction and other focused automation | End-to-end tests or features that require full Chrome behavior |
| Performance | Can be faster for suitable jobs | Depends on workload and configuration |
Chrome describes the old Headless shell as a lightweight wrapper around Chromium’s //content module with substantially fewer dependencies. Treat performance as workload-dependent; no universal benchmark applies.
2. Fastest working setup with the Puppeteer image
Puppeteer’s maintained image includes Chrome for Testing and the required dependencies. Pin a version or digest for reproducible CI; the latest tag moves. Verify the current tag in the official Docker guide before publishing.
docker run -i --init --cap-add=SYS_ADMIN --rm \
ghcr.io/puppeteer/puppeteer:<pinned-version> \
node -e "const puppeteer = require('puppeteer'); (async () => { const browser = await puppeteer.launch({headless: 'shell'}); const page = await browser.newPage(); await page.goto('https://example.com', {waitUntil: 'networkidle2'}); await page.screenshot({path: '/tmp/example.png', fullPage: true}); await browser.close(); })();"
--init ensures browser child processes are reaped. The documented image run uses --cap-add=SYS_ADMIN for its sandboxed browser configuration. Keep the sandbox enabled whenever possible and run as a suitable non-root user. Do not casually replace it with --no-sandbox; Puppeteer’s guidance treats that as appropriate only when all loaded content is absolutely trusted.
A maintainable project layout
project/
├── Dockerfile
├── package.json
└── capture.js
// capture.js
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch({
headless: 'shell',
userDataDir: '/tmp/chrome-profile',
args: ['--window-size=1440,900']
});
try {
const page = await browser.newPage();
await page.setViewport({width: 1440, height: 900, deviceScaleFactor: 1});
await page.goto(process.env.TARGET_URL || 'https://example.com', {
waitUntil: 'networkidle2',
timeout: 60000
});
await page.screenshot({path: '/tmp/page.png', fullPage: true});
} finally {
await browser.close();
}
})();
{
"private": true,
"scripts": {"capture": "node capture.js"},
"dependencies": {"puppeteer": "<pin-the-version-you-approve>"}
}
FROM ghcr.io/puppeteer/puppeteer:<pinned-version>
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY capture.js ./
ENV XDG_CONFIG_HOME=/tmp/chrome-config \
XDG_CACHE_HOME=/tmp/chrome-cache
CMD ["node", "capture.js"]
docker build --tag headless-shell-capture .
docker run --rm --init --cap-add=SYS_ADMIN \
--env TARGET_URL=https://example.com \
headless-shell-capture
Use a pinned Puppeteer release and its compatible browser build. Puppeteer’s installer downloads a Chrome for Testing build and Shell binary intended to work with that release.
3. Install Headless Shell yourself
For a custom base image or a non-Node service, Chrome for Testing’s browser manager can fetch the binary:
npx @puppeteer/browsers install chrome-headless-shell@stable
# Or pin an approved version:
npx @puppeteer/browsers install chrome-headless-shell@<version>
Install the shared libraries required by that binary for your distribution, create a writable profile and cache location, and run as an appropriate user. The exact packages vary by base distribution and binary build, so do not copy a universal library list between Debian, Ubuntu, Alpine or distroless images without checking the binary’s dependencies. Keep browser and automation-library versions aligned.
Custom image checklist
- Start from a distribution supported by the Chrome for Testing build.
- Install the browser’s required shared libraries and fonts.
- Fetch a pinned
chrome-headless-shellrelease. - Set writable
XDG_CONFIG_HOMEandXDG_CACHE_HOME, or provide an explicit PuppeteeruserDataDir. - Create a non-root runtime user and preserve the sandbox.
- Use an init-capable entrypoint.
- Record the browser and image versions in build metadata.
4. Running the Shell directly from the command line
Once chrome-headless-shell is on PATH, Chrome’s CLI supports DOM serialization, screenshots and PDFs:
# Serialize the DOM after parsing and script execution
chrome-headless-shell --headless --dump-dom https://example.com > page.html
# Capture a screenshot at a defined viewport
chrome-headless-shell --headless --screenshot=/tmp/page.png \
--window-size=1440,900 https://example.com
# Print a PDF without browser-added headers and footers
chrome-headless-shell --headless --print-to-pdf=/tmp/page.pdf \
--no-pdf-header-footer https://example.com
# Bound how long the operation waits
chrome-headless-shell --headless --timeout=30000 \
--screenshot=/tmp/page.png https://example.com
--dump-dom is not the same as downloading the original HTML: Chrome parses the page and runs scripts before serializing it. The CLI reference is documented by Chrome for Developers.
5. Browser lifecycle and capture options in Puppeteer
Wait for the right readiness signal
await page.goto(url, {waitUntil: 'domcontentloaded', timeout: 60000});
await page.waitForSelector('#app', {timeout: 30000});
await page.waitForNetworkIdle({idleTime: 500, timeout: 30000});
Use domcontentloaded for fast static pages, a selector for application readiness, and network idle only when background polling will eventually settle. For pages with continuously open connections, an explicit selector or bounded delay is safer.
Full-page, element and PDF output
await page.screenshot({path: '/tmp/full.png', fullPage: true});
await page.locator('.invoice').screenshot({path: '/tmp/invoice.png'});
await page.pdf({path: '/tmp/invoice.pdf', format: 'A4', printBackground: true});
Viewport, device scale and browser flags
await page.setViewport({width: 1280, height: 800, deviceScaleFactor: 2});
const browser = await puppeteer.launch({
headless: 'shell',
args: ['--window-size=1280,800']
});
Shell needs --enable-gpu when GPU acceleration is desired and the container host provides a usable GPU. Most CI screenshot jobs should omit it and use CPU rendering.
6. Security, storage and reliability
- Sandbox: preserve Chrome’s sandbox and use a non-root user. The Chrome FAQ says
--no-sandboxis unnecessary when the container user is properly configured. - No X server: Headless Shell does not need a display window, so Xvfb is unnecessary.
- Writable paths: Chrome writes profile, configuration and cache data at startup. Read-only containers must provide writable locations or mounts.
- Process cleanup: use Docker
--initor an init-capable entrypoint to prevent orphaned renderer processes. - Resource limits: set explicit CPU, memory and timeout limits. A page can create many renderer processes or hold connections indefinitely.
- Untrusted URLs: isolate jobs, restrict outbound networking where appropriate, and do not disable the sandbox for convenience.
- Retries: retry transient navigation failures with a bounded count and backoff, but do not blindly retry deterministic HTTP errors or bot challenges.
7. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Browser exits immediately | Missing shared libraries or incompatible browser/Puppeteer versions | Use the maintained Puppeteer image, inspect startup stderr, and align pinned versions. |
Failed to move to new namespace or sandbox errors |
Container security settings do not support the configured sandbox | Use the documented --cap-add=SYS_ADMIN setup, run as a suitable user, and keep the sandbox enabled. |
| Zombie Chrome processes | No init process | Start with Docker --init or an init-capable entrypoint. |
| Read-only filesystem errors | Profile or cache directory is not writable | Set XDG_CONFIG_HOME, XDG_CACHE_HOME and userDataDir to writable paths. |
| Blank or incomplete screenshot | Capture occurs before application rendering or lazy content loads | Wait for a meaningful selector, then wait for network idle or a bounded delay. |
| Timeout on dynamic pages | Long polling, stalled resources or bot checks | Use a specific readiness condition, block unnecessary resources where safe, and retain a hard timeout. |
| Fonts or layout differ from local Chrome | Different fonts, viewport, device scale or browser version | Install required fonts, set viewport and scale explicitly, and pin the browser build. |
| GPU acceleration has no effect | No compatible GPU is exposed to the container | Only add --enable-gpu when the host and runtime are configured for GPU access. |
8. Performance, reliability and cost decisions
Performance
Shell can reduce image size and startup work because it has fewer dependencies, but actual throughput depends on page complexity, fonts, network, JavaScript execution, concurrency and storage. Measure your own workload before choosing a concurrency value. Reuse a browser process for a controlled queue, create isolated pages or contexts per job, and close them promptly.
Reliability
Pin image, Puppeteer and browser versions together. Log the target URL, browser version, navigation timing, exit status and screenshot dimensions. Keep timeouts at navigation, readiness and job levels so one page cannot occupy a worker indefinitely.
Cost
Self-hosting shifts cost to container CPU, memory, storage and operations. Browser startup, parallel renderers and large full-page captures are the main drivers. For sporadic or high-volume image generation, a screenshot API can remove browser image maintenance and capacity planning.
9. Or skip the browser setup
ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns PNG, JPEG, WebP or PDF. It accepts cookie and consent banners before capture, removes more than 60 known consent platforms plus newsletter popups and chat widgets, and lets each cleanup step be turned off. Bot checks, blank pages, timeouts, failed loads and cache hits are not billed; response headers identify the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info and capture_pdf for Claude, Cursor and other MCP clients.
See the ScreenshotNeo API documentation for all options.
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(`HTTP ${res.status}`);
require('fs').writeFileSync('shot.webp', Buffer.from(await res.arrayBuffer()));
ScreenshotNeo includes full-page capture with lazy images loaded, CSS element capture, dark mode, device presets and custom viewports, retina scale, PDF controls, custom CSS and JavaScript, clicks, waits, request blocking, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, selectable cache TTLs, signed image links, asynchronous jobs with signed webhooks, bulk capture for up to 100 URLs per call, a usage API and an OpenAPI specification. Its parameter names are compatible with those used by other screenshot APIs, which helps with migrations.
Plans include 1,000 screenshots per month free with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
10. FAQ
Is Headless Shell a separate Chrome download?
Yes. Since Chrome 132, the former old Headless implementation is distributed as the standalone chrome-headless-shell; regular Chrome’s --headless flag uses Unified Headless.
Can I use Xvfb?
You do not need it for Headless Shell because no display window is created.
Should every container use --no-sandbox?
No. Configure the container user and sandbox first. Disable it only for content you completely trust and where you understand the isolation trade-off.
When should I choose Unified Headless?
Choose it when browser fidelity or a feature unavailable in Shell matters more than a leaner binary and dependency set.
Why did an old Docker example use Node 8?
Chrome’s FAQ contains a historical Lighthouse CI example based on node:8-slim. It is not a current Shell image recommendation.


