DEV Community

Cover image for How to Run Headless Chrome in Docker for Screenshots (and the Maintenance Tax It Adds)
Nico Acosta for Grabbit

Posted on • Originally published at grabbit.live

How to Run Headless Chrome in Docker for Screenshots (and the Maintenance Tax It Adds)

You can run headless Chrome in Docker, and for a controlled workload it is the right call. The catch is that a bare apt install chromium plus chrome --headless does not work: Chrome refuses to start, crashes on big pages, renders text as empty boxes, and leaves zombie processes behind. This guide gives you a Dockerfile that actually runs, explains each flag that makes it work, and names the ongoing maintenance so you can decide whether to run Chrome yourself or hand the render off to an API.

The short answer

To run headless Chrome in a container you need four things the base image does not give you:

  1. Chromium plus its shared libraries (a slim base ships neither).
  2. The right launch flags: --no-sandbox, --disable-gpu, --disable-dev-shm-usage.
  3. Fonts, or every screenshot renders text as boxes.
  4. An init process to reap the zombie Chrome children, plus a non-root user so --no-sandbox is not a gaping hole.

Miss any one and it fails in a way whose error message does not name the real cause. That is the whole reason this problem has a dozen Stack Overflow answers.

A Dockerfile that works

FROM node:20-slim

# Chromium + the shared libs a slim base is missing, fonts, and an init.
RUN apt-get update && apt-get install -y --no-install-recommends \
      chromium \
      fonts-liberation fonts-noto-color-emoji \
      libnss3 libatk-bridge2.0-0 libgbm1 libasound2 \
      tini \
    && rm -rf /var/lib/apt/lists/*

# Run as a non-root user, because we launch with --no-sandbox below.
RUN useradd --create-home chrome
USER chrome
WORKDIR /home/chrome

ENV CHROME_BIN=/usr/bin/chromium

# tini (PID 1) reaps the zombie Chrome children a long-running renderer leaves.
ENTRYPOINT ["tini", "--"]
CMD ["chromium", \
     "--headless=new", \
     "--no-sandbox", \
     "--disable-gpu", \
     "--disable-dev-shm-usage", \
     "--screenshot=/home/chrome/out.png", \
     "--window-size=1280,720", \
     "https://example.com"]
Enter fullscreen mode Exit fullscreen mode

Build and run it, then copy the screenshot out:

docker build -t chrome-shot .
docker run --rm -v "$PWD:/out" chrome-shot \
  chromium --headless=new --no-sandbox --disable-dev-shm-usage \
  --screenshot=/out/out.png --window-size=1280,720 https://example.com
Enter fullscreen mode Exit fullscreen mode

Everything after chrome-shot overrides the CMD, so you can point it at any URL without rebuilding.

What each flag is actually fixing

--no-sandbox is the flag everyone hits first. Chrome's sandbox needs Linux user namespaces and kernel calls that Docker's default seccomp and AppArmor profiles block, so Chrome cannot fork its sandboxed children and exits with a terse Failed to move to new namespace. --no-sandbox skips the sandbox. It is a real security boundary you are removing, which is exactly why the Dockerfile above runs as a non-root chrome user: trusted URLs plus a non-root user keeps the blast radius small.

--disable-dev-shm-usage is the one that bites later, in production, on a page you did not test with. Docker mounts /dev/shm at 64MB by default. Chrome uses that shared-memory region for its render process, and a large or image-heavy page overruns it, crashing the tab with session deleted because of page crash. This flag tells Chrome to write to /tmp instead. The alternative is docker run --shm-size=1gb, but the flag travels with the image, so prefer it.

--disable-gpu avoids the container trying to reach a GPU that is not there. Harmless to keep even where it is no longer strictly required.

--headless=new opts into the modern headless mode (the default implementation since Chrome 112), which is the same browser as headful Chrome, so what you capture matches what a user sees. Older tutorials use bare --headless, which on current Chrome maps to the new mode anyway.

The parts the tutorials skip

The Dockerfile runs. That is not the same as being done, and the gap is where the maintenance tax lives.

  • Fonts. A slim base image ships no fonts, so text renders as tofu boxes and emoji disappear. You install font packages by hand (fonts-liberation, fonts-noto-color-emoji, and CJK packs if you capture non-Latin pages). Every new script you need to render is another package to remember.
  • Zombie processes. Chrome spawns child processes, and a long-running container without a real init as PID 1 accumulates defunct entries until the process table is a mess. That is what tini (or dumb-init, or docker run --init) is for.
  • Memory growth. Chrome's memory climbs over a long-lived process, and under load it does not always come back down. The common production workaround is to restart the renderer on a schedule or after N captures, which is its own moving part to build and watch. This is the exact pain developers describe: Chromium memory leaks forcing you to constantly kill and restart pods.
  • Patching. A pinned Chromium is a frozen attack surface. Keeping it current means rebuilding the image on Chrome's release cadence, then re-testing that none of the flags above regressed.

None of this is hard once. All of it is forever.

When to hand the render to an API instead

Running Chrome in Docker earns its keep when you want full control of the browser, capture at high volume where per-request cost matters, or need the container for more than screenshots. If all you need is "give me an image of this URL," the container is a lot of standing infrastructure for one HTTP call.

A hosted screenshot API removes the image, the flags, the fonts, the /dev/shm sizing, the zombie reaping, and the restart cron. You send a URL and get a hosted image back:

curl -X POST https://grabbit.live/api/v1/grabs \
  -H "Authorization: Bearer sk_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "width": 1280,
    "height": 720,
    "full_page": true,
    "format": "webp"
  }'
Enter fullscreen mode Exit fullscreen mode

The response is JSON with a hosted image_url you can cache or embed. The same request handles the things the container makes you handle yourself: it waits for the page, sizes the viewport, and captures the full scrolling page when you pass full_page: true. Add delay_ms (0 to 10000) to wait for late-loading content, format (png, jpeg, or webp) to pick the output, or selector to crop to a single element.

Grabbit is prepaid at a flat $0.002 per live grab, and the credits do not reset or expire monthly, so a spiky screenshot workload does not pay for a subscription tier it never fills. Test-environment keys return a placeholder for free, so you can wire it up before adding a card. This is not the cheapest per-grab rate on the market, but it is one HTTP call with no container to keep alive. See the screenshot API for the full parameter reference.

The honest trade is control versus operations. If you want the browser under your own roof, the Dockerfile above is a solid start, and the maintenance failure modes are worth reading up front. If you would rather not run Chrome at all, skipping the container is the point of the API.


Originally published on the Grabbit blog.

Top comments (0)