How to Screenshot an HTML Page with Go and Headless Chrome in Docker
Build a Go screenshot service with chromedp and headless Chrome in Docker. Capture a full page, viewport, or element, then handle container setup and common failures.
Use chromedp to control Chrome through the Chrome DevTools Protocol (CDP), navigate to a page, capture its image bytes, and write them to a file. For Docker, the chromedp/headless-shell image is a practical browser runtime designed for chromedp. The example below captures a full-page PNG. Use a selector screenshot for one element, or CaptureScreenshot for the current viewport.
Go controls the browser; it does not render the page itself. Your container needs both the Go program and a compatible Chrome runtime. The chromedp package reference documents the screenshot actions, while the project’s screenshot example shows the capture flow.
1. Create a runnable Go screenshot program
This program accepts a URL and output path, loads the page, waits for the document load event through navigation, and saves a full-page PNG. It applies an overall deadline so a page that never finishes cannot tie up the process indefinitely.
// go.mod
module example.com/go-shot
go 1.22
require github.com/chromedp/chromedp v0.13.7
Use a currently supported chromedp version compatible with your Go toolchain; pin the version you build and deploy. The version above is an example dependency declaration, so check the package’s current release and compatibility before adopting it.
// main.go
package main
import (
"context"
"fmt"
"log"
"net/url"
"os"
"time"
"github.com/chromedp/chromedp"
)
func main() {
if len(os.Args) != 3 {
log.Fatal("usage: go-shot https://example.com output.png")
}
target := os.Args[1]
output := os.Args[2]
parsed, err := url.ParseRequestURI(target)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
log.Fatal("URL must be an absolute http or https URL")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
browserCtx, cancelBrowser := chromedp.NewContext(ctx)
defer cancelBrowser()
var image []byte
if err := chromedp.Run(browserCtx,
chromedp.Navigate(target),
chromedp.FullScreenshot(&image, 100),
); err != nil {
log.Fatalf("capture %q: %v", target, err)
}
if len(image) == 0 {
log.Fatal("Chrome returned an empty screenshot")
}
if err := os.WriteFile(output, image, 0o644); err != nil {
log.Fatalf("write %q: %v", output, err)
}
fmt.Printf("saved %s (%d bytes)\n", output, len(image))
}
The invocation is:
go mod tidy
go run . https://example.com page.png
FullScreenshot takes a quality from 0 to 100. At 100, chromedp returns PNG; lower values select JPEG and the quality setting applies. Give the output a matching extension. If you need viewport PNG bytes directly, use chromedp.CaptureScreenshot(&image). See the screenshot action implementation and API notes.
2. Package the program and browser in Docker
Build the Go executable in a builder stage, then copy it into the headless-shell image. The chromedp documentation recommends the headless-shell image as a straightforward headless environment; its README documents the image and tags. The default chromedp allocator looks for a Chrome-compatible executable, and headless-shell is built for this integration.
# Dockerfile
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum* ./
RUN go mod download
COPY main.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/go-shot .
FROM docker.io/chromedp/headless-shell:stable
COPY --from=build /out/go-shot /usr/local/bin/go-shot
ENTRYPOINT ["/usr/local/bin/go-shot"]
Build and run with Docker’s init process enabled, which the image project recommends to reap child processes:
docker build -t go-shot .
docker run --rm --init -v "$PWD:/work" go-shot https://example.com /work/page.png
The output directory must be mounted and the path passed to the program must be writable inside the container. The example pins the Go image family but uses the floating Chrome stable channel. For repeatable browser builds, replace it with a specific headless-shell version tag. The project says channel images are pushed daily, so floating tags can change between builds.
This single-container pattern assumes the image’s browser executable is discoverable by chromedp. If your base image or browser path differs, configure a chromedp ExecAllocator with the correct executable path and required flags for that runtime. The browser and CDP protocol versions must work together.
3. Choose the right capture boundary
| Need | Action | Behavior |
|---|---|---|
| One visible element | chromedp.Screenshot("#report", &image, chromedp.NodeVisible) |
Captures the matching visible node. A missing selector returns an error. |
| Current viewport | chromedp.CaptureScreenshot(&image) |
Captures the currently visible browser viewport. |
| Whole page | chromedp.FullScreenshot(&image, 100) |
Captures beyond the viewport; quality 100 produces PNG, lower quality produces JPEG. |
For an element screenshot, navigate and capture in the same task list. Add a targeted wait if the element is rendered asynchronously:
var image []byte
err := chromedp.Run(ctx,
chromedp.Navigate(target),
chromedp.WaitVisible("#report", chromedp.ByQuery),
chromedp.Screenshot("#report", &image, chromedp.NodeVisible),
)
if err != nil {
return err
}
if err := os.WriteFile("report.png", image, 0o644); err != nil {
return err
}
Use chromedp.WaitVisible, WaitReady, or a bounded delay when the page needs client-side rendering. A fixed sleep is simple but can be wasteful on fast pages and too short on slow ones. Prefer a selector tied to the content you need, when possible.
4. Configure viewport and browser lifecycle
For a screenshot of the current viewport at a chosen size, set the viewport before navigation and call CaptureScreenshot:
err := chromedp.Run(ctx,
chromedp.EmulateViewport(1440, 1000),
chromedp.Navigate(target),
chromedp.CaptureScreenshot(&image),
)
FullScreenshot overrides device emulation settings. If full-page capture and emulation are both required, account for that behavior explicitly; do not assume your device metrics remain in effect. The upstream example calls out this interaction and points to resetting device settings when needed.
By default, chromedp starts Chrome headlessly. Its context owns the browser lifetime: defer cancellation, keep the context alive until capture and file writing are complete, and check errors from chromedp.Run. A lost or killed browser connection cancels the context. On Linux, chromedp force-kills Chrome child processes it started when the Go program exits.
5. Run Chrome as a separate container (optional)
Use a separate browser when it has an independent lifecycle or is shared by an application. The headless-shell README shows port 9222 for the debugging endpoint. Keep that endpoint on a private container network; remote debugging grants control of the browser and should not be exposed broadly.
docker network create screenshot-net
docker run -d --name chrome --network screenshot-net --init \
docker.io/chromedp/headless-shell:stable
For a separately managed browser, chromedp provides RemoteAllocator, which connects to an existing Chrome DevTools websocket URL. The Go process needs to reach that endpoint. Discover or pass the websocket endpoint through your deployment configuration, then connect as follows:
allocCtx, cancelAlloc := chromedp.NewRemoteAllocator(context.Background(), websocketURL)
defer cancelAlloc()
ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()
var image []byte
err := chromedp.Run(ctx,
chromedp.Navigate(target),
chromedp.FullScreenshot(&image, 100),
)
Here websocketURL is the browser’s reachable CDP websocket URL, not merely a guessed hostname and port. Obtain the actual endpoint from the browser’s debugging metadata. The remote browser must remain available for the whole capture.
6. Tune reliability, performance, and cost
- Bound every job. Use a context deadline and return a useful error when navigation or rendering exceeds it. Select a deadline appropriate to your own pages; the example’s 60 seconds is a starting value, not a performance guarantee.
- Wait for the required content. Navigation completion does not mean every SPA widget, lazy image, or late network request has rendered. Wait for a page-specific selector when the screenshot depends on it.
- Control page size. Full-page images can be large and expensive in memory, especially for long documents. Capture an element or viewport if that fits the use case; avoid processing huge pages concurrently without resource limits.
- Reuse browser infrastructure deliberately. Starting Chrome for every job adds process startup overhead. A separately managed browser can avoid repeated launches, but requires lifecycle management, isolation, and private CDP networking.
- Set container resources based on workload. Browser memory use depends on the page, viewport, image dimensions, and concurrency. Observe your own container memory and CPU before choosing limits; no universal resource figure applies.
- Account for page access and networking. The container must resolve and reach the target site. Authenticated pages may require cookies or headers, and sites can reject automation or block the container’s network.
- Pin reproducible inputs. Pin your Go module versions and use a specific headless-shell version tag when browser changes could affect output. A floating channel receives updates and can change.
- There is no per-shot browser API fee in this setup. Your costs are the compute, memory, storage, and network resources you operate. Scale concurrency to the capacity of your own deployment.
7. Troubleshoot common failures
| Symptom | Likely cause | Fix |
|---|---|---|
exec: "google-chrome": executable file not found or Chrome cannot start |
No browser runtime is installed, its binary is not discoverable, or the selected image differs from the expected setup. | Use the chromedp headless-shell image or install a compatible Chrome binary. Configure the allocator’s executable path when needed. |
Container exits with BUS_ADRERR |
The browser may need more shared memory. | The image README recommends trying a larger allocation, for example --shm-size 2G, when this crash occurs. |
| Zombie browser processes accumulate | The container’s process 1 is not reaping child processes. | Start Docker with --init. The headless-shell project documents this recommendation. |
context canceled or browser connection closed |
The browser exited, was killed, or the context deadline expired. | Check container logs and resource limits, ensure the browser stays alive, and set a realistic job deadline. Retry only transient failures. |
| Selector not found / element screenshot error | The selector is wrong, the element has not rendered, or it is in a frame or shadow DOM not addressed by the query. | Inspect the selector, wait for visibility, and use the appropriate frame or DOM approach for the page structure. |
| Blank or incomplete screenshot | Capture happened before client-side content or images loaded, or the target site served an interstitial. | Wait for a meaningful selector or page-specific readiness condition. Check the returned page and network behavior from inside the container. |
| Output file missing or empty | The path is not writable, the directory is not mounted, or capture returned no bytes. | Use a writable mounted path, verify the byte slice is non-empty, and check os.WriteFile errors. |
| PNG file contains JPEG bytes (or vice versa) | FullScreenshot quality and filename extension disagree. |
Use quality 100 for PNG; use a lower quality for JPEG and a .jpg or .jpeg extension. |
| Remote browser cannot be reached | Go cannot resolve or connect to the CDP websocket endpoint, or the browser stopped. | Put the containers on a shared private network, use the actual websocket URL, and confirm the remote browser is listening. |
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server from ScreenshotNeo. One GET request returns an image or PDF, and the API documentation describes its options. For example, save a WebP capture with cURL:
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://example.com \
-o shot.webp
Python:
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://example.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://example.com',
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);
Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. An MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Sign up free and get 1,000 screenshots a month with no card.
FAQ
Can I screenshot a local HTML file?
Yes. Navigate to a file:// URL that points to a file visible inside the browser container. Mount the containing directory into the container and use the container’s file path. Browser security restrictions can affect local pages that load other local resources.
Does headless Chrome need a desktop or display server?
No desktop display is needed for chromedp’s default headless operation. You still need a compatible browser executable and its runtime dependencies.
Does a full-page screenshot preserve mobile emulation?
Do not assume so. chromedp documents that FullScreenshot overrides device emulation settings. Use viewport capture for a viewport-sized result and account for the full-page behavior if you need emulation.
Should I launch Chrome per request?
For a small command-line tool, the default lifecycle is simplest. For a continuously running service with many captures, a managed browser may reduce startup work, but requires careful lifecycle and access control for the CDP endpoint.


