Running Headless Browsers Inside Docker Containers
A guide to closing the dependency gaps that crash headless Chrome in Docker.

Running a headless browser in Docker sounds simple until you actually try it. Chrome is not a lightweight utility; it is a full browser engine that expects a well-resourced desktop environment, and a minimal container is about as far from that as you can get — like asking a concert pianist to perform on a toy keyboard. Getting this right requires deliberate decisions at every layer: base image, memory allocation, sandbox configuration, process management, and CI integration. Make those decisions once, correctly, and the setup is remarkably stable. Skip any of them and you will spend hours staring at cryptic crash logs.
What Chrome Actually Needs That a Minimal Container Doesn't Have
Chrome is not a single binary. It is a runtime environment with a long list of system-level dependencies that your host OS provides silently and a minimal container does not provide at all.
The gaps show up fast. Graphics libraries like libX11, libXcomposite, and libXdamage are missing. The shared memory device at /dev/shm is constrained to a fraction of what Chrome expects. Linux sandboxing mechanisms, specifically namespaces and seccomp, need explicit configuration. System fonts are absent, which causes pages to render incorrectly even when the browser technically loads. Playwright's Chromium specifically needs libgbm, libnss3, and libatk-bridge2.0. None of these come with a stock Debian or Ubuntu container by default.
Locally, you never notice any of this because the host OS supplies all of it silently. The Docker gap only surfaces at runtime, usually as a cryptic segfault or a blank page with no useful error message. That experience is disorienting the first time it happens, because the browser "launched" from the framework's perspective. It just immediately crashed at a layer below where your code can observe it.
The dependency gap is the root cause of the majority of first-time failures. Everything that follows is about closing it systematically.
Choosing a Base Image Before Writing a Single Line of Dockerfile
The most consequential decision you make in this entire setup happens before the first RUN instruction: which base image you start from.
Alpine is tempting. The compressed image size is genuinely appealing, especially for teams paying per GB in egress costs. But Alpine uses musl libc rather than glibc, and that difference causes real incompatibilities with Playwright and Chromium. Treat Alpine as a trap for this use case unless you have a specific reason to work around those limitations and the appetite to do so.
Debian and Ubuntu variants are the safe default. Every major Node.js release is built against a Debian base, glibc is present, and the package ecosystem is well-understood. One caveat with Debian: the Chromium version bundled in the official Debian repositories lags behind the latest Puppeteer release, creating a version mismatch that fails silently. The fix is to install the Google Chrome Stable Debian package directly from Google, which gives you the current stable release and sidesteps the version lag entirely.
For teams who would rather not hunt down every missing library themselves, several pre-configured options exist. Microsoft's official Playwright image, mcr.microsoft.com/playwright:v1.62.0, is built on Ubuntu 24.04 LTS and comes with everything Playwright needs already installed. Selenium publishes selenium/standalone-all-browsers, which bundles all browsers in a single image from tag 4.35.0 onward; as of the 4.46.0-20260707 release it weighs in at 10.9 GB, so budget accordingly. For teams that genuinely need a smaller footprint and can tolerate Alpine's constraints, zenika/alpine-chrome comes in at a compressed 423 MB and is a reasonable community option.
The decision heuristic is straightforward: reach for an official, purpose-built image unless image size is a hard constraint. The debugging time you save outweighs the download cost by a significant margin.
The Shared Memory Limit That Crashes Chrome Before It Renders a Single Page
Docker's default shared memory allocation is 64 MB. Chrome needs significantly more than that.
Chrome uses /dev/shm to share rendering data between its multiple internal processes. When the limit is hit, the result is crashes, blank pages, or, if you are using Selenium, the "session deleted because of page crash" error. That error is particularly misleading because it implies a test problem rather than an infrastructure problem.
The fix is one line: pass --shm-size=2g to docker run, or set shm_size: "2g" in Docker Compose. For headless testing workloads, plan for 512 MB to 1 GB of shared memory per container depending on how many sessions run concurrently.
There is a tempting workaround: --disable-dev-shm-usage, which forces Chrome to write to /tmp instead of /dev/shm. This prevents the crash, but it degrades performance noticeably. Browserless's production best practices explicitly recommend against this flag in favor of properly raising shm_size. Use the workaround only if you truly cannot control the container's shared memory allocation; otherwise, fix the root cause.
Chrome's Multi-Process Architecture and What It Means for Container Memory Budgets
Launching Chrome spawns multiple processes simultaneously: a browser process, a GPU process, and a separate renderer process for each tab or isolated context. This is by design; it is what gives Chrome its stability and security properties on the desktop. Inside a container with a memory limit, it requires explicit accounting.
A practical mental model: the baseline browser process consumes roughly 100 MB. Each open tab adds somewhere between 50 MB and 200 MB depending on page complexity. Without a ceiling on concurrent tabs, memory grows unbounded until the container hits its limit and gets killed, usually mid-test.
CPU cost compounds the problem. Launching a fresh Chrome instance per request is unsustainable at any real volume. The initialization overhead alone makes this approach impractical beyond trivial workloads. Cold-starting Chrome for every request is like hiring a full orchestra every time someone wants to hear a single note.
The recommended pattern is a warm browser pool: keep a pool of browser instances running and reuse them across requests rather than cold-starting for each one. A practical concurrency rule that keeps memory stable and prevents cross-request state leakage is one connection per browser instance. Do not multiplex multiple concurrent sessions through a single browser instance unless you have tested that the framework handles it cleanly.
One more thing that is easy to overlook: use tini as your Docker entrypoint. Chrome spawns subprocesses that can become zombies when they exit. Without a proper init process, those dead renderer processes accumulate silently, consuming PIDs and memory. tini handles zombie reaping correctly, and it should be a standard part of any containerized browser setup.
Sandbox Configuration and the Security Tradeoffs of Running Chrome as Root
Docker containers run as root by default. Chrome's Chromium sandbox is disabled when the process owner is root, because the sandbox relies on namespace isolation that root already bypasses.
Playwright's official Docker image reflects this reality honestly: it runs browsers as root and documents the sandbox trade-off explicitly. This is not an oversight; it is a deliberate acknowledgment that the threat model in a controlled CI environment is different from the threat model of a public-facing scraper.
The right configuration depends on what the browser is actually doing. For internal CI pipelines running trusted test code, --no-sandbox is acceptable. The sandboxing overhead is pure cost with no meaningful security benefit when the JavaScript being executed is your own test suite. For scraping or crawling arbitrary external pages where the browser executes untrusted JavaScript, --no-sandbox is unacceptable. The sandbox exists precisely to contain what untrusted content can do to the host process.
The better path for scraping workloads is to create a non-root user inside the container and apply a Chrome-specific seccomp profile. A well-maintained version of that profile is in the jessfraz dotfiles repository and is a reasonable starting point. With a non-root user and the right seccomp profile, the Chromium sandbox still functions correctly.
One more flag worth discussing is --ipc=host. Playwright's Docker documentation recommends this flag explicitly. Without it, Chromium exhausts shared IPC resources and crashes even when /dev/shm is correctly sized. It is almost always worth enabling.
To summarize the three-way decision: root plus --no-sandbox for trusted CI workloads; non-root plus a seccomp profile for untrusted content; --ipc=host as a near-universal recommendation in both cases.
Framework-Specific Setup Paths for Puppeteer, Playwright, and Selenium Grid
The high-level principles are the same across frameworks, but each one has its own configuration surface that rewards knowing in advance.
Puppeteer
Puppeteer will attempt to download its own bundled Chromium during npm install. Inside a Docker build, that download is wasteful at best and broken at worst. Set ENV PUPPETEERSKIPCHROMIUM_DOWNLOAD true to suppress it, then install the Google Chrome Stable Debian package in the same RUN layer. The result is one controlled, up-to-date Chrome binary that Puppeteer connects to via the Chrome DevTools Protocol. This approach also resolves the Debian-bundled Chromium version lag described earlier.
Playwright
Use mcr.microsoft.com/playwright:v1.62.0 as your base image, and match the image version to the Playwright version in your project exactly. This is not optional. Playwright locates browser executables relative to the version that installed them; a mismatch means it cannot find them at runtime.
Playwright also supports a server mode where the browser runs inside the container and tests connect from the host or a remote machine over a network connection. This is useful when the host OS is an unsupported Linux distribution or when you want to isolate browser execution from the test runner. Avoid Alpine for any Playwright setup; glibc compatibility is not negotiable here.
Selenium Grid 4
Grid 4 provisions Docker containers on demand per session: each test gets a fresh container, and that container is discarded on completion. This is a clean model that eliminates shared state between test runs and keeps infrastructure costs proportional to actual usage.
The selenium/standalone-all-browsers image bundles all browsers in a single image from tag 4.35.0 onward. The 10.9 GB size is real and worth planning around in environments with storage or egress constraints. One practical note for GitHub Actions users: Selenium images are now mirrored to GitHub Container Registry as an official alternative to Docker Hub. Docker Hub rate limits affect CI pipelines that pull images repeatedly, particularly in open-source projects where runners are unauthenticated. GHCR pulls are more reliable in high-frequency or unauthenticated contexts.
When a Pre-Built Managed Image Is Worth It Instead of a Hand-Rolled Dockerfile
Hand-rolling a Dockerfile for Chrome is not a one-time task. It is an ongoing maintenance surface. You are tracking missing system libraries, managing font packages, tuning shared memory, setting sandbox flags, handling session cleanup, and revisiting all of it when a dependency updates. That work is tractable, but it is real work, and it accumulates.
Browserless is one managed option in this space. It handles missing system fonts, missing external libraries, session management, and performance tuning. It supports Puppeteer, Playwright, and Selenium without forking any of them, and it also exposes REST APIs for common tasks like PDF generation, screenshots, and scraping, which is useful when the automation need is simple enough that a full framework is overkill. Browserless publishes to ghcr.io/browserless/ and supports both linux/amd64 and linux/arm64, with the caveat that Chrome and Edge are amd64-only; ARM deployments get Chromium, Firefox, and WebKit. An enterprise tier adds intelligent scraping strategies, a crawl API, and an MCP server for teams building AI-connected automation pipelines.
The decision between managed and hand-rolled comes down to where browser automation sits in your product. If it is a supporting capability, the kind of thing that generates PDFs for invoices or runs browser checks in CI, a managed image is almost always the right choice. The time saved on configuration and maintenance has a real opportunity cost. If browser automation is a core product concern where you need specific version pinning, unusual security policy controls, or a minimal image footprint, hand-rolling gives you the control that managed images do not.
Connecting a Containerized Browser Setup to a CI/CD Pipeline
The primary value of containerizing your browser setup for CI is consistency. The same image that runs on your development machine runs in the pipeline, eliminating the entire category of "works locally" failures that plague browser test suites in non-containerized environments.
In GitHub Actions, the pattern is straightforward: pull the browser image, run the test suite in a container step. The shared memory sizing, sandbox configuration, and user setup done in the Dockerfile carry forward into CI without any pipeline-specific changes. That portability is the point.
Selenium Grid's on-demand container provisioning fits CI workloads particularly well. There are no long-lived browser processes sitting idle between test runs, which means no resource waste during the intervals when tests are not executing. Each session gets a clean container, and cleanup is automatic.
Registry choice matters more in CI than it does locally. Docker Hub rate limits affect pipelines that pull images frequently, particularly in open-source projects where runners are unauthenticated. The GHCR mirror for Selenium images and Browserless's GHCR publishing both address this directly. For GitHub Actions specifically, GHCR pulls are authenticated by default using the GITHUB_TOKEN, which eliminates rate limit concerns entirely.
Version pinning is non-negotiable in production pipelines. Floating tags like :latest introduce silent breakage when a base image updates. An update to a base image pulls in a new Chromium build, a changed seccomp profile, or a different library version — changes you never audited or tested. Pin to a specific version tag, such as :v1.62.0, and update deliberately as part of a scheduled dependency review cycle.
For parallel test execution, the memory math becomes critical. Multiply the per-container memory budget, roughly 100 MB baseline plus tab overhead, by the number of parallel workers to set container memory limits correctly. Under-provisioned limits produce mid-run OOM kills that are genuinely hard to distinguish from test failures. Size the limits before you run at scale, not after.
