Self-hosted service
chromedp/docker-headless-shell avatar
chromedp/docker-headless-shell

docker-headless-shell: a minimal Chrome container for CDP automation

Minimal container for Chrome's headless shell, useful for automating / driving the web

671 stars80 forksShellMIT

At a glance

What is it?
docker-headless-shell packages Chrome's stripped down headless binary as a multi-arch container image built for the Go chromedp package and any other Chrome DevTools Protocol client. It ships fast, hides the headless user agent, and leaves the debugging port as the only contract you get.
Who is it for?
Adopt docker-headless-shell if you drive Chrome through the DevTools Protocol and want a small, multi-arch base image whose user agent matches a normal Chrome build. Skip it if you need a full browser with extensions, GUI rendering features or an official upstream image.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 19 days ago.
What is it written in?
Mainly Shell, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

A container that carries one stripped down Chrome binary

The headless-shell project publishes a multi-arch container image, docker.io/chromedp/headless-shell, that contains Chrome's headless-shell binary rather than a full Chrome installation. The documentation describes it as a slimmed down version of Chrome that is useful for driving, profiling, or testing web pages. That framing matters: this is not a browser distribution with a desktop environment attached, it is the smallest runnable piece of Chromium that can still accept DevTools Protocol commands.

The image was created for the Go chromedp package, which offers an API for driving browsers over the Chrome Debugging Protocol, but the constraint is deliberately loose. Any library or application that speaks the protocol can attach, so the image is not tied to Go even though the surrounding tooling is. The repository itself is written in Shell, holds an MIT license, and its top level files show a build pipeline rather than a library: a Containerfile, build.sh, build-image.sh, install.sh, lib.sh, notify.sh, run.sh, test.sh and cleanup.sh, plus systemd units for the scheduled work.

For teams that only need page automation, profiling or test runs, this is a deliberately narrow image. The trade-off is that nothing beyond the browser binary is guaranteed, so anything your workload needs, such as fonts, a shell entrypoint or a process supervisor, has to be layered on by you.

The user agent was changed to look like a regular Chrome

One modification is worth calling out before you deploy this. The version of headless-shell contained in the image has been modified from the original Chromium source tree so that it reports the same user agent as Chrome, along with other minor changes aimed at making it better suited to an embedded context.

The practical effect is that code which branches on the user agent string will not see the usual headless marker. The flip side is that the user agent is no longer a way to tell a real browser apart from a containerised one. If your application needs to distinguish human traffic from automation, this image removes that signal rather than adding one.

Because the binary is patched rather than taken verbatim from upstream Chromium, the image is not interchangeable with a stock headless build. Any behaviour you rely on that depends on exact Chromium build internals should be re-checked against this binary rather than assumed from upstream documentation, since the patch set here is small but undocumented in detail beyond the note about the user agent and embedded use.

Pulling a floating channel tag or pinning an exact version

Multi-arch images for Chrome's stable, beta and dev channels are pushed daily to the docker.io/chromedp/headless-shell repository. You can pull the floating channel tags or a specific version tag. The commands below show the documented set:

sh
# pull latest stable
$ podman pull docker.io/chromedp/headless-shell:latest

# pull specific version
$ podman pull docker.io/chromedp/headless-shell:123.0.6312.86

# pull beta
$ podman pull docker.io/chromedp/headless-shell:beta

# pull dev
$ podman pull docker.io/chromedp/headless-shell:dev

Pull the exact version tag when you want repeatable runs, since the channel tags move every day as the nightly build lands. The example version tag above, 123.0.6312.86, is the pinned form the documentation shows, so treat it as the shape of the tag rather than as the version you should use.

The examples use podman, and the same image reference works for Docker and other OCI runtimes. The image is hosted on Docker Hub under the chromedp organisation, and the README does not describe a self hosted registry workflow for consumers, so plan for the public registry unless your own infrastructure mirrors the tag.

Port 9222 is the whole interface, and you can override the entrypoint

The container is driven entirely over the Chrome Debugging Protocol, and the documented run command publishes the debugging port on the host:

sh
# run
$ podman run -d -p 9222:9222 --rm --name headless-shell docker.io/chromedp/headless-shell

A client attaches to the port you mapped and speaks the protocol to it. There is no configuration file and no sidecar service to wire up.

The unprivileged variant shows what the image exposes when you take control of the entrypoint. It overrides the entrypoint with the binary path, passes the remote debugging address and port explicitly, and disables the GPU path:

sh
# run as unprivileged user
# get seccomp profile from https://raw.githubusercontent.com/jfrazelle/dotfiles/master/etc/docker/seccomp/chrome.json
$ podman run -d -p 9222:9222 --user nobody --security-opt seccomp=chrome.json --entrypoint '/headless-shell/headless-shell' docker.io/chromedp/headless-shell --remote-debugging-address=0.0.0.0 --remote-debugging-port=9222 --disable-gpu --enable-unsafe-swiftshader --headless

Note that binding to 0.0.0.0 on the debugging port exposes it on every interface of the host, and the seccomp profile is not shipped with the image. Fetch it from the external repository shown in the comment and keep it in your own tree, otherwise the container start command will fail on a missing file.

Shared memory limits and zombie processes are the two runtime snags

Running the plain container often works, but two failure modes are called out in the documentation. The first is a crash with a BUS_ADRERR error, which comes from too little shared memory for the browser process:

sh
# if headless-shell is crashing with a BUS_ADRERR error, pass a larger shm-size:
$ podman run -d -p 9222:9222 --rm --name headless-shell --shm-size 2G docker.io/chromedp/headless-shell

The second is zombie processes, which can appear when the image is used directly or as a base image. The fix is the init flag of the runtime:

sh
$ podman run -d -p <PORT>:<PORT> --name <your-program> --init <your-image>

For Docker older than 1.13.0 the documentation points at dumb-init or tini in the ENTRYPOINT of a Dockerfile built on the image:

dockerfile
FROM docker.io/chromedp/headless-shell:latest
...
# Install dumb-init or tini
RUN apt install dumb-init
# or RUN apt install tini
...
ENTRYPOINT ["dumb-init", "--"]
# or ENTRYPOINT ["tini", "--"]
CMD ["/path/to/your/program"]

Both are operational details rather than features, but they decide whether long running jobs leak processes over time.

A nightly Arch Linux build that ships only the resulting binaries

The image is not assembled inside a container. It is built nightly on an Arch Linux host by the scripts in this repository, and the build runs directly on the host, with only the resulting binaries packaged into the container image. The host needs a fairly long list of packages, installed with yay:

sh
$ yay -S --needed \
    base-devel git git-lfs python perl curl jq go bzip2 \
    aarch64-linux-gnu-binutils gperf \
    buildah podman qemu-user-static qemu-user-static-binfmt

git-lfs is called out as required, because Chromium's third_party/litert holds Git LFS files and gclient sync fails without it. The go toolchain exists only to install verhist, which the documentation says is not packaged:

sh
$ go install github.com/chromedp/verhist/cmd/verhist@latest

That binary has to sit on the PATH of the user running the build. The rest of the workflow is systemd based:

sh
# enable lingering and the timers
$ sudo loginctl enable-linger $USER
$ ./install.sh
$ systemctl enable --now --user headless-shell.timer

# run a build by hand, see all options with --help
$ ./build.sh --help
$ ./build.sh --channel stable --target amd64

# follow a build
$ journalctl --user -fu headless-shell.service

Pushing to the registry needs a token stored in ~/.config/headless-shell/token. Failures trigger headless-shell-failure.service, which runs notify.sh to post the tail of the build log to a Discord channel, and the webhook URL lives in ~/.config/headless-shell/discord-webhook. This is infrastructure for the publisher, not for consumers.

What the image does not give you

The scope is narrow, and several things people assume are present are not. There is no GUI, no extension support, and no browser management. The image contains the browser binary, so a base image built on it has to add its own shell, supervisor and any system libraries you need.

The release story is equally unusual. The repository has no GitHub releases, so there is no changelog to read and no version history page to scan. Version information lives entirely in the image tags, which is why pulling a specific version tag is the only way to pin an upgrade. The documentation also does not document a rollback procedure for a bad build, so keeping the previous tag locally is the practical answer.

On the licence side, the project is MIT, which is permissive, but the Chromium binary inside the image carries its own licensing and notices, and the user agent patch means you are running a modified build. Anything that verifies the browser version against a stock distribution will need to be checked against this one. Compared with running a full Chrome container, the win is a smaller footprint and a DevTools Protocol endpoint with no display server; the loss is everything else the full browser gives you.

Editorial conclusion

Adopt docker-headless-shell if you drive Chrome through the DevTools Protocol and want a small, multi-arch base image whose user agent matches a normal Chrome build. Skip it if you need a full browser with extensions, GUI rendering features or an official upstream image. Before committing, check whether the port 9222 endpoint satisfies your client, verify the shared memory flag your workload needs, and pin a specific version tag such as 123.0.6312.86 rather than a floating channel tag.

Frequently asked questions

How do I connect to the docker-headless-shell container from chromedp?

Publish the debugging port with -p 9222:9222 and point your client at that port. The image exposes the Chrome DevTools Protocol, which is what the Go chromedp package speaks, and any library that supports the protocol can attach instead.

Why does headless-shell crash with a BUS_ADRERR error?

The documented fix is a larger shared memory allocation. Add --shm-size 2G to the run command so the browser has enough shared memory to start.

Do I need an init process to avoid zombie processes?

Yes, when you use the image directly or as a base image, pass the runtime's --init flag. On Docker older than 1.13.0 the documentation points to dumb-init or tini in the ENTRYPOINT instead.

Does the browser in this image identify itself as headless?

No. The headless-shell binary in the image was modified from the original Chromium source tree so it reports the same user agent as Chrome, with other minor changes for embedded use.

Official sources

  1. chromedp/docker-headless-shell on GitHub
  2. Issues
  3. License: MIT
  4. Project website
  5. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/chromedp-docker-headless-shell.svg)](https://hysenlabs.com/projects/chromedp-docker-headless-shell)

Was this useful?