ScreenshotNeo

BlogHow-to

Create Website Screenshots in Go with chromedp Inside a Docker Container

Capture element, viewport, or full-page screenshots with Go and chromedp in Docker. Includes runnable code, browser setup, and container troubleshooting.

By the ScreenshotNeo team4 October 202611 min read

To create website screenshots in Go with chromedp inside Docker, run a Chrome-compatible browser alongside your Go program, navigate to a page, choose an element, viewport, or full-page capture action, and write the returned image bytes to persistent storage. chromedp drives Chrome through the Chrome DevTools Protocol (CDP); it is not itself a browser.

For the simplest headless setup, the chromedp project recommends running the Go program inside the chromedp/headless-shell image. This guide builds that shape and also explains how to connect to a separate browser container.

1. Choose the screenshot and container setup

First decide what image you need. These capture operations produce different results:

Need chromedp action Behavior
A visible page region, such as a card or chart chromedp.Screenshot(selector, &buf, chromedp.NodeVisible) Captures the matching visible element. The query fails if there is no matching node.
What is currently in the viewport chromedp.CaptureScreenshot(&buf) Captures the current browser viewport.
The entire page chromedp.FullScreenshot(&buf, quality) Captures the full-size page. Quality 100 selects PNG; other values select JPEG.

There are two common Docker layouts:

  • One image: the Go program and browser runtime run in the same container. This keeps the browser local to the application and is the simplest starting point.
  • Separate browser service: run headless-shell separately and connect to its DevTools endpoint with chromedp’s remote allocator. This separates browser and application lifecycles, but requires network access between containers and careful protection of the debugging endpoint.

The official chromedp examples include both containerized and remote-browser examples. These patterns have operational tradeoffs, not a published performance comparison; choose based on deployment boundaries, lifecycle, and how you want to pin browser versions.

2. Write a runnable Go screenshot program

Create a module and install chromedp. Pin a version in your application’s module file so builds use a deliberate dependency version.

mkdir chromedp-shot
cd chromedp-shot
go mod init example.com/chromedp-shot
go get github.com/chromedp/chromedp

Save this as main.go. It navigates to a URL, waits for the document body, then captures either the viewport, full page, or a CSS-selected element. It writes the bytes to the output path.

package main

import (
	"context"
	"flag"
	"fmt"
	"log"
	"os"
	"time"

	"github.com/chromedp/chromedp"
)

func main() {
	url := flag.String("url", "https://example.com", "page URL to capture")
	out := flag.String("out", "/out/screenshot.png", "output image path")
	selector := flag.String("selector", "", "optional CSS selector to capture")
	fullPage := flag.Bool("full-page", false, "capture the full page")
	flag.Parse()

	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
	defer cancel()

	// chromedp.NewContext uses the local browser discovery/launch path.
	ctx, cancelBrowser := chromedp.NewContext(ctx)
	defer cancelBrowser()

	var image []byte
	actions := []chromedp.Action{
		chromedp.Navigate(*url),
		chromedp.WaitReady("body", chromedp.ByQuery),
	}

	if *selector != "" {
		actions = append(actions,
			chromedp.Screenshot(*selector, &image, chromedp.NodeVisible),
		)
	} else if *fullPage {
		actions = append(actions, chromedp.FullScreenshot(&image, 100))
	} else {
		actions = append(actions, chromedp.CaptureScreenshot(&image))
	}

	if err := chromedp.Run(ctx, actions...); err != nil {
		log.Fatalf("capture failed: %v", err)
	}
	if err := os.MkdirAll(parentDir(*out), 0755); err != nil {
		log.Fatalf("create output directory: %v", err)
	}
	if err := os.WriteFile(*out, image, 0644); err != nil {
		log.Fatalf("write screenshot: %v", err)
	}
	fmt.Printf("wrote %s (%d bytes)\n", *out, len(image))
}

func parentDir(path string) string {
	for i := len(path) - 1; i >= 0; i-- {
		if path[i] == '/' {
			if i == 0 {
				return "/"
			}
			return path[:i]
		}
	}
	return "."
}

The program uses a 60-second overall deadline, including browser startup, navigation, readiness, and capture. Adjust it for your workload. The default capture is the viewport; set -full-page for the whole document or -selector for a visible matching element.

3. Run it in the headless-shell container

The headless-shell project publishes channel tags such as stable, beta, and dev, as well as version tags. Channel images are refreshed daily, so pin an image version when repeatability matters. Check the project’s current registry tags and confirm the selected image’s entrypoint and browser path before relying on them in a production build.

For a quick run, mount the source directory and output directory. Replace CHROMEDP_HEADLESS_SHELL_TAG with a tag verified in the image registry:

docker run --rm \
  --shm-size=2g \
  --mount type=bind,src="$PWD",dst=/work \
  --mount type=bind,src="$PWD/out",dst=/out \
  -w /work \
  chromedp/headless-shell:CHROMEDP_HEADLESS_SHELL_TAG \
  sh -c 'go run . -url https://example.com -out /out/example.png'

This invocation assumes the selected image provides Go and supports the shown command and working directory. Verify its contents and entrypoint first. If it does not include Go, build an application image that includes a compatible browser binary, or use the separate browser service pattern below. The output mount ensures the screenshot is written outside the container’s disposable writable layer.

If the documented BUS_ADRERR crash occurs, headless-shell’s documentation recommends increasing shared memory; the example uses --shm-size 2g. It is a symptom-specific troubleshooting setting, not a universal requirement.

4. Build an application image with a browser runtime

For repeatable deployment, create an application image with Go and a compatible browser runtime. The exact browser binary path and startup behavior depend on the headless-shell tag and base image, so verify those details for the version you pin. A general multi-stage Go build can look like this:

# Replace the base image with a verified headless-shell tag that supports
# your deployment's required build/runtime tools and browser discovery.
FROM chromedp/headless-shell:CHROMEDP_HEADLESS_SHELL_TAG AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/screenshot .

# For a single-image deployment, use a runtime image that contains the
# compatible browser and the required shared libraries. Verify the image's
# browser path and entrypoint for the pinned tag.
FROM chromedp/headless-shell:CHROMEDP_HEADLESS_SHELL_TAG
COPY --from=build /app/screenshot /usr/local/bin/screenshot
ENTRYPOINT ["/usr/local/bin/screenshot"]

Build and run, mounting a host directory for durable output:

docker build -t chromedp-shot .
mkdir -p out
docker run --rm \
  --mount type=bind,src="$PWD/out",dst=/out \
  chromedp-shot \
  -url https://example.com -out /out/example.png

This Dockerfile is a deployment outline, not a promise that every headless-shell tag is a Go build image. If your selected browser image lacks the compiler or libraries needed by the build stage, use a Go builder stage and a verified browser runtime stage. Ensure the runtime can find and execute the browser expected by your chromedp version.

5. Connect to a separate browser container

Use a separate service when you want browser processes managed independently from the Go application. Start headless-shell on a private container network and connect to its DevTools WebSocket endpoint with chromedp’s remote allocator. The endpoint URL depends on the selected image and its startup arguments; inspect the image documentation and configure it to expose the DevTools endpoint on the private network.

package main

import (
	"context"
	"log"
	"os"
	"time"

	"github.com/chromedp/chromedp"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
	defer cancel()

	// Supply the WebSocket debugger URL reachable from this application
	// container. Keep the endpoint on a trusted private network.
	browserURL := os.Getenv("CHROME_WS_URL")
	if browserURL == "" {
		log.Fatal("CHROME_WS_URL must be set to the browser WebSocket endpoint")
	}

	allocCtx, cancelAllocator := chromedp.NewRemoteAllocator(ctx, browserURL)
	defer cancelAllocator()
	browserCtx, cancelBrowser := chromedp.NewContext(allocCtx)
	defer cancelBrowser()

	var image []byte
	if err := chromedp.Run(browserCtx,
		chromedp.Navigate("https://example.com"),
		chromedp.WaitReady("body", chromedp.ByQuery),
		chromedp.CaptureScreenshot(&image),
	); err != nil {
		log.Fatalf("capture failed: %v", err)
	}
	if err := os.WriteFile("/out/example.png", image, 0644); err != nil {
		log.Fatal(err)
	}
}

Set CHROME_WS_URL to the actual DevTools WebSocket URL visible from the application container, not an assumed hostname or port. Do not expose a remote debugging port beyond the network boundary that requires it. A remote browser lets the browser service have its own lifecycle; it also adds endpoint availability, network configuration, and browser/application version coordination to your deployment.

6. Wait for the page state you need

Navigate completing does not guarantee that a client-rendered page has finished drawing the content you want. The example waits for body as a basic readiness condition. For a dynamic page, wait for a page-specific element or state that means the relevant content is ready.

// Wait for a known result to appear before capturing it.
chromedp.WaitVisible(".report-chart", chromedp.ByQuery)

Use selectors that correspond to the content being captured. A fixed sleep can sometimes hide a race, but it adds delay on fast pages and may still be too short on slow ones. A selector-based wait expresses the required page state more directly. The chromedp examples include visibility-wait patterns; the screenshot example does not prescribe one universal wait for every site.

7. Understand formats, quality, and emulation behavior

  • Viewport capture: CaptureScreenshot returns the current viewport capture. The viewport is determined by the browser’s current emulation or window configuration.
  • Element capture: Screenshot uses a selector and requests a visible node in this example. A missing selector is an error; a hidden element may not satisfy the visible-node condition. Element captures use PNG in the implementation described by the chromedp source.
  • Full page: FullScreenshot accepts quality from 0 to 100. Quality 100 selects PNG; any other value selects JPEG. The official example uses 90.
  • Emulation side effect: FullScreenshot is an emulation action and overrides device emulation settings. If later actions depend on a previous viewport or device profile, reset emulation after the full-page capture.

If the result differs from Chrome DevTools’ element-screenshot UI, check the implementation behavior for the exact chromedp version you pinned. The implementation comments identify DevTools commands the helper does not itself send, so do not assume every detail matches the DevTools interface.

8. Troubleshoot common container and capture errors

Symptom Likely cause What to check or change
Chrome cannot be found or launched The container does not contain a compatible Chrome/Chromium binary, or chromedp cannot discover its path. Verify the selected image tag, its browser path, and entrypoint. Use a compatible headless-shell runtime or configure the browser executable as appropriate for the pinned setup.
BUS_ADRERR or a browser crash under load Insufficient shared memory is a documented headless-shell troubleshooting case. Try a larger Docker shared-memory allocation, such as --shm-size=2g, and confirm whether the symptom changes.
Zombie browser child processes Container process handling may not reap child processes. Use the container runtime’s --init option. The headless-shell documentation also shows dumb-init or tini for older Docker setups.
Sandbox or permission failure The container’s user, seccomp profile, or browser security settings do not match the image and host environment. Check the image’s security guidance and deployment restrictions. The project documents an unprivileged nobody example with a Chrome seccomp profile. Avoid treating disabled sandbox protections as a harmless default.
Capture times out on a JavaScript-heavy page The relevant content was not ready before the operation deadline, or page loading stalled. Wait for a page-specific selector or completion condition, allow an appropriate deadline, and inspect whether the target page actually loaded.
Selector query fails The CSS selector matches no nodes, or the matching node is not visible when a visible node is required. Check the selector against the rendered DOM, wait for it to appear, and confirm it is visible before capture.
Image exists during the run but disappears later The output was written only to the container’s writable layer, which may be removed with the container. Write to a bind mount or volume, or upload the bytes to application storage before the container exits.
Remote allocator cannot connect The WebSocket endpoint is wrong, unreachable from the application container, or not enabled by the browser service. Check the browser service startup configuration, endpoint URL, private network, and service readiness. Keep the debugger endpoint protected.

9. Plan for repeatability, reliability, and cost

Repeatability: pin the chromedp module dependency and browser image version when you need reproducible builds. The headless-shell project says its stable, beta, and dev channel images are pushed daily, so floating channel tags can change. Verify current tags and compatibility when updating.

Reliability: set an overall context deadline and always defer the cancellation functions returned by context.WithTimeout and chromedp.NewContext. chromedp’s FAQ notes that on Linux it force-kills Chrome child processes to avoid leaking resources when contexts are canceled. If a browser must outlive a short-lived Go task, the documented approach is to start Chrome separately and connect using RemoteAllocator.

Storage: screenshot bytes are only useful if they reach durable storage. A mounted volume or application storage avoids relying on a container layer that may disappear when the container is removed.

Performance and cost: the cited chromedp materials do not publish benchmarks or a cost comparison for these layouts. Image dimensions, page behavior, browser startup, and deployment shape affect the work, so measure your own workload before sizing it. Full-page images can contain much more content than viewport captures. A shared browser service changes lifecycle and isolation choices but is not inherently a published performance or cost improvement.

10. Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server from Yorker Media. One GET request returns a PNG, JPEG, WebP, or PDF. Its API offers full-page capture, selector capture, viewport and device options, waits, custom CSS and JavaScript, and other capture controls. See the ScreenshotNeo API documentation for parameters and configuration.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before capture; each cleanup step can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers report the page verdict and billing status. Its MCP server gives AI agents tools for screenshots, page information, and PDF capture. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots.

Sign up free for 1,000 screenshots a month, with no card required.

FAQ

Is chromedp a browser?

No. It is a Go client for controlling browsers that support CDP. Your container needs a compatible Chrome or Chromium runtime, or your program must connect to a remote browser.

Should I capture the viewport or the full page?

Use CaptureScreenshot for the currently visible viewport, FullScreenshot for the entire page, and Screenshot with a selector for one visible element.

Can the screenshot survive container removal?

Yes. Write it to a mounted volume or send it to durable application storage instead of relying on the container’s writable layer.

Why does my full-page capture change device emulation?

FullScreenshot overrides emulation settings. Reapply the desired emulation if later actions need the earlier viewport or device profile.