Docker Screenshot API

Call ScreenshotRun from any Docker container. No Chromium binary, no font packages, no /dev/shm tuning. curl, Node.js, Python examples, Docker Compose setup.

Vitalii Holben Updated

Every Dockerfile that bundles Chromium tells the same story. It starts with apt-get install chromium, picks up a dozen font and rendering packages along the way, and finishes at 950 MB to 1.9 GB. Your CI pipeline slows to a crawl. Your $6 VPS runs out of memory after three concurrent captures. And then /dev/shm fills up at 3 AM, the renderer crashes with a SIGBUS, and nobody on call knows what shared memory even is.

The ScreenshotRun docker screenshot API skips all of that. Your container sends a GET request, our infrastructure runs headless Chrome, and you get back an image: PNG, JPEG, WebP, AVIF, or PDF. The browser never enters your container. No Chromium binary, no Puppeteer, no font packages. Just HTTP.

Docker container sends GET request to ScreenshotRun API, which renders the page in headless Chrome and returns the image — no Chrome in your container

Your first docker screenshot API call

Grab an API key from the ScreenshotRun dashboard. The free plan includes 200 captures per month with every parameter available, no card on file.

From any container that has curl (Alpine, Debian slim, distroless with a shell, doesn't matter):

curl -G "https://api.screenshotrun.com/v1/screenshots/capture" \
  -H "Authorization: Bearer $SCREENSHOTRUN_KEY" \
  -d "url=https://github.com" \
  -d "format=png" \
  -d "response_type=image" \
  -o screenshot.png

That's the whole integration. The -o flag writes the image to disk; swap it for piping to S3, stdout, or whatever your pipeline expects. No runtime dependencies, no browser binary.

Minimal Dockerfile — no browser, no bloat

A screenshot-capable container without Chrome looks like this:

# Node.js example — 150MB total, not 1.2GB
FROM node:20-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .

ENV SCREENSHOTRUN_KEY=""
EXPOSE 3000
CMD ["node", "server.js"]

Compare that with the typical Puppeteer Dockerfile floating around StackOverflow:

# The "traditional" approach — 950MB to 1.9GB
FROM node:20-slim

RUN apt-get update && apt-get install -y \
    chromium \
    fonts-liberation \
    fonts-noto-color-emoji \
    fonts-freefont-ttf \
    libatk-bridge2.0-0 \
    libdrm2 \
    libgbm1 \
    libnss3 \
    libxkbcommon0 \
    libxshmfence1 \
    --no-install-recommends \
    && rm -rf /var/lib/apt/lists/*

ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true

WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .

EXPOSE 3000
CMD ["node", "server.js"]

Twelve extra packages, two environment variables that break whenever Puppeteer bumps its Chromium version, and an image size that makes CI pipelines crawl. On a $6 VPS, Chromium alone could eat half your available memory before a single screenshot fires. We've seen build times jump by four minutes just from the font packages.

Node.js screenshot service inside Docker

A typical pattern for building a screenshot service Docker teams actually ship to production: an Express route that proxies capture requests through your backend. The API key stays server-side, your frontend never sees it.

// server.js
import express from 'express';

const app = express();
const API_URL = 'https://api.screenshotrun.com/v1/screenshots/capture';

app.get('/screenshot', async (req, res) => {
  try {
    const url = req.query.url;
    if (!url) return res.status(400).json({ error: 'url is required' });

    const params = new URLSearchParams({
      url,
      format: req.query.format || 'png',
      width: req.query.width || '1280',
      response_type: 'image'
    });

    const response = await fetch(`${API_URL}?${params}`, {
      headers: { 'Authorization': `Bearer ${process.env.SCREENSHOTRUN_KEY}` }
    });

    if (!response.ok) {
      return res.status(502).json({ error: `API returned ${response.status}` });
    }

    const contentType = req.query.format === 'pdf' ? 'application/pdf' : `image/${req.query.format || 'png'}`;
    res.set('Content-Type', contentType);
    res.set('Cache-Control', 'public, max-age=3600');

    const buffer = Buffer.from(await response.arrayBuffer());
    res.send(buffer);
  } catch (err) {
    res.status(500).json({ error: 'Screenshot capture failed' });
  }
});

app.listen(3000, () => console.log('Running on :3000'));

No puppeteer import. No browser.launch() with a wall of Chrome flags. The full Node.js integration guide covers error handling, retries, and batch processing in more depth.

Python container with requests

Same idea for a Python-based container. A FastAPI endpoint that takes a URL, calls the API, streams the image back:

# app.py
import os, requests
from fastapi import FastAPI
from fastapi.responses import Response

app = FastAPI()
API_URL = "https://api.screenshotrun.com/v1/screenshots/capture"

@app.get("/screenshot")
def capture(url: str, format: str = "png", width: int = 1280):
    resp = requests.get(API_URL, params={
        "url": url,
        "format": format,
        "width": width,
        "response_type": "image",
    }, headers={
        "Authorization": f"Bearer {os.environ['SCREENSHOTRUN_KEY']}"
    }, timeout=60)

    resp.raise_for_status()

    media = "application/pdf" if format == "pdf" else f"image/{format}"
    return Response(content=resp.content, media_type=media)
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

The image pulls in Python, uvicorn, requests, FastAPI. About 180 MB total. Good luck getting Playwright to fit in that budget. The Python integration page has async examples with httpx if you prefer that route.

Docker Compose with environment variables

A production-ready Compose file. The API key lives in an .env file or your secrets manager; the container never stores it on disk:

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - SCREENSHOTRUN_KEY=${SCREENSHOTRUN_KEY}
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3

Notice what's missing: no shm_size: '2g', no cap_add: SYS_ADMIN, no --init flag to reap zombie Chrome processes. That entire class of Docker configuration disappears when you use a docker screenshot API instead of bundling the browser.

What breaks when you run Chrome in Docker

Ever wondered why your container suddenly dies at 3 AM with no logs? If you've searched for a reliable headless chrome docker screenshot setup, you've hit at least three of these walls.

Docker allocates 64 MB of shared memory by default. Chromium's renderer uses /dev/shm for inter-process communication, and 64 MB runs out fast on anything beyond a trivial page. The symptom: a SIGBUS crash with no obvious error message. The fix (--disable-dev-shm-usage or --shm-size=2g) trades speed for stability because Chrome falls back to writing temporary files to /tmp instead.

Then there are zombie processes. Chrome spawns child processes for each renderer tab. If your Node app runs as PID 1 inside the container (the default), it won't reap those children when they exit. Over time, zombie renderers pile up. The docker run --init flag (or tini as entrypoint) fixes this, but most Docker tutorials skip it. GitHub issues from 2026 still report this in Linkwarden, Penpot, and other tools that embed headless Chrome.

Fonts are another trap. Minimal Docker images ship without any. CJK characters, emoji, and anything outside basic Latin render as tofu boxes (empty rectangles where glyphs should be). You need fonts-liberation, fonts-noto-color-emoji, and fonts-freefont-ttf at minimum, plus an fc-cache -fv rebuild. Miss one package and half the web looks broken in your screenshots. We handle this on our end with a full font stack covering Latin, CJK, Arabic, Hebrew, Devanagari, and emoji.

Memory is probably the worst offender. Each Chromium renderer tab consumes 100–300 MB of RAM depending on page complexity. Ten concurrent screenshots on a 2 GB VPS and the OOM killer starts dropping processes. Puppeteer's GitHub has an open issue (#5893) about Chrome memory leaks that's been collecting comments for years without a definitive fix.

And the security side: Chromium's sandbox requires kernel capabilities (SYS_ADMIN) or a carefully configured non-root user. Most Docker setups take the shortcut: --no-sandbox. It works, but disables the layer that prevents a compromised renderer from reaching the host filesystem.

What ScreenshotRun handles for you

Each screenshot request spins up an ephemeral Chrome instance on our infrastructure. Fresh browser, no cookies, no cache, no history from previous captures. The instance loads your target URL, waits for JavaScript to finish, fires the capture, and shuts down. Your container never touches Chrome.

Because Chrome never enters your stack, the problems listed above don't transfer. Fonts? We maintain a full set covering Latin, CJK, Arabic, Hebrew, Devanagari, and emoji, the same set that would cost you 200 MB of apt-get packages. Shared memory? Sized for rendering, not constrained by Docker's 64 MB default. Zombie processes and sandbox flags? Not your concern anymore.

On top of the rendering engine, the API gives you things that would take weeks to build from scratch with Puppeteer. Cookie consent dialogs, ad banners, and floating chat widgets each have a dedicated toggle (block_cookies, block_ads, block_chats) instead of the custom click scripts and filter lists you'd maintain yourself. Setting stealth=true rotates browser fingerprints so protected sites don't serve a CAPTCHA wall. Output comes in six formats (PNG, JPEG, WebP, AVIF, PDF) plus scrolling video capture, while Puppeteer limits you to PNG and JPEG without post-processing.

Need to capture a page in its dark color scheme? One parameter flips prefers-color-scheme before the first paint. Want to see how a site renders in a different country? Geolocation targeting handles that, which is useful for e-commerce listing verification or compliance checks across regions.

The free plan includes 200 captures per month with every parameter unlocked, no feature gating, no watermarks. If you outgrow it, paid plans start at $9/month.

Migrating from Puppeteer in Docker to the screenshot API

If you're looking for a puppeteer docker alternative that doesn't require Chrome in your image, the migration is simpler than you'd expect. Puppeteer options map almost directly to API parameters. The table below covers the most common calls.

Puppeteer codeScreenshotRun parameter
page.setViewport({ width: 1440 })width=1440
page.screenshot({ fullPage: true })full_page=true
page.screenshot({ type: 'webp' })format=webp
page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }])dark_mode=true
page.waitForSelector('.content')wait_for_selector=.content
page.evaluate(() => { ... })js=... (up to 10,000 characters)
page.addStyleTag({ content: '...' })css=...
page.screenshot({ clip: { ... } })selector=.target-element

Rip out the Puppeteer dependency, remove the Chromium packages from your Dockerfile, replace page.screenshot() calls with HTTP requests. That's the whole migration for most setups. Your image drops from north of a gigabyte to a few hundred megabytes, and the next Chrome security advisory becomes somebody else's problem.

One thing the API won't cover: if you're screenshotting your own locally-rendered HTML that isn't accessible via a public URL, you'll need to either use the html parameter (send raw HTML to the API) or expose the page behind a URL the API can reach. The HTML-to-image feature handles the first case.

Saving screenshots to a volume or S3

Inside a Docker container, you typically want screenshots going somewhere persistent: a mounted volume, S3, or another storage backend.

# Save to a mounted volume
docker run -v $(pwd)/screenshots:/data myapp \
  sh -c 'curl -G "https://api.screenshotrun.com/v1/screenshots/capture" \
    -H "Authorization: Bearer $SCREENSHOTRUN_KEY" \
    -d "url=https://example.com" \
    -d "response_type=image" \
    -o /data/example.png'
# Pipe directly to S3 (no temp file)
curl -s -G "https://api.screenshotrun.com/v1/screenshots/capture" \
  -H "Authorization: Bearer $SCREENSHOTRUN_KEY" \
  -d "url=https://example.com" \
  -d "format=webp" \
  -d "response_type=image" \
  | aws s3 cp - s3://my-bucket/screenshots/example.webp

The pipe-to-S3 pattern works well for ephemeral containers (CI runners, cron jobs, Lambda-like setups) where you don't want to manage local disk at all. Bytes flow from the API straight into S3 without touching the filesystem. No temp file, no cleanup.

Scheduled captures with Docker cron

A lightweight cron container that takes daily screenshots of a list of URLs. No browser in the image. Just curl and a shell script:

FROM alpine:3.19
RUN apk add --no-cache curl
COPY capture.sh /usr/local/bin/capture.sh
RUN chmod +x /usr/local/bin/capture.sh
COPY crontab /etc/crontabs/root
CMD ["crond", "-f"]
#!/bin/sh
# capture.sh
DATE=$(date +%Y-%m-%d)
URLS="https://your-app.com https://competitor.com https://news.ycombinator.com"

for url in $URLS; do
  SLUG=$(echo "$url" | sed 's|https\?://||;s|/|-|g;s|\.$||')
  curl -s -G "https://api.screenshotrun.com/v1/screenshots/capture" \
    -H "Authorization: Bearer $SCREENSHOTRUN_KEY" \
    -d "url=$url" \
    -d "full_page=true" \
    -d "response_type=image" \
    -o "/data/screenshots/${SLUG}_${DATE}.png"
  echo "Captured: $SLUG ($DATE)"
done
# crontab — runs every day at 6 AM UTC
0 6 * * * /usr/local/bin/capture.sh

The Alpine image is 8 MB. Add curl and you're at 12 MB. That's the entire screenshot infrastructure. Mount a volume at /data/screenshots for persistence, or replace the -o flag with an S3 upload. For visual change detection over time, pair these daily captures with website change monitoring.

API parameters

All of these work from any Docker container that can make HTTP requests. Pass them as query string parameters on the GET request:

ParameterTypeDefaultWhat it does
urlstringrequiredTarget page to capture
formatstringpngOutput: png, jpeg, webp, avif, or pdf
widthinteger1280Viewport width in pixels
heightinteger720Viewport height in pixels
full_pagebooleanfalseTake one continuous image from the top of the page to the very bottom
devicestringdesktopViewport preset: desktop, mobile, or tablet
retinabooleanfalseDouble the DPI for crisp output on Retina and 4K displays (expect roughly 4x the file weight)
dark_modebooleanfalseFlip the browser's prefers-color-scheme to dark
selectorstring—Crop to a single DOM element
delayinteger—Seconds to wait after page load before capturing (0–10)
wait_for_selectorstring—Hold until this CSS selector appears in the DOM
block_cookiesbooleanfalseClear away cookie consent popups so they don't appear in the capture
block_adsbooleanfalseSuppress ad banners, interstitials, and tracking-heavy third-party embeds
block_chatsbooleanfalseHide Intercom, Drift, Crisp, and other live-chat bubbles that float over content
cssstring—Inject custom CSS before the capture
jsstring—Run JavaScript after load, before the screenshot
stealthbooleanfalseBypass bot-detection scripts on protected sites
response_typestringjsonWhat comes back: json (URL + metadata), image (raw bytes), or base64

Find the full reference in the API docs. One Docker-specific tip: if your container has no writable filesystem (distroless or read-only mounts), use response_type=base64 and handle the image in memory. The base64 feature page covers that pattern.

Self-hosted Docker screenshots vs. ScreenshotRun API

Should you keep running a self-hosted screenshot API Docker setup, or switch to a managed service? We built ScreenshotRun partly because we got tired of debugging this exact stack in production. The comparison below comes from that experience.

Self-hosted Chrome in DockerScreenshotRun API
Docker image size950 MB – 1.9 GB (Node + Chromium + fonts)Your app only (50–200 MB)
/dev/shm tuningRequired — --shm-size=2g or crashesNot your problem
Chrome sandbox--no-sandbox or CAP_SYS_ADMINNot applicable
Font coverageManual: apt-get + fc-cache for CJK/emojiFull stack preinstalled (Latin, CJK, Arabic, emoji)
Zombie process cleanup--init flag or tini entrypointNot applicable
Memory per capture100–300 MB per Chrome tabHTTP response buffer only
Concurrent captures3–5 per container before degradationRate-limited by plan, scales horizontally on our side
Chromium updatesYour responsibility — new version every 4 weeksManaged by ScreenshotRun
Output formatsPNG, JPEG, PDF (Puppeteer); more with PlaywrightPNG, JPEG, WebP, AVIF, PDF, scrolling video
Cookie/ad blockingWrite Puppeteer scripts per siteOne parameter each: block_cookies, block_ads, block_chats
Bot detection bypassMaintain stealth patches yourselfstealth=true
Cost at 1K captures/monthVPS + your engineering hoursFree plan (200/mo) or $9/mo Starter
Cost at 50K captures/monthMultiple containers + load balancer + ops time$49/mo Pro plan

Self-hosted Chrome makes sense in one scenario: you need to screenshot your own app's pages that aren't publicly accessible, and you're already running Chrome for other reasons (end-to-end testing, PDF generation from internal views). For capturing external URLs at scale (monitoring, thumbnails, archival, product listing snapshots), a hosted docker screenshot API strips a whole infrastructure layer out of your Compose stack.

One honest tradeoff: the API adds network latency. A local Chrome tab renders in 1–3 seconds; the API call adds round-trip time on top of rendering. For interactive use cases where sub-second response matters, local Chrome is faster. The gap closes for batch workloads and disappears entirely when you factor in cold-start time for a Docker container that isn't already running.

FAQ#

No. ScreenshotRun runs headless Chrome on its own infrastructure. Your container only needs to make an HTTP request (curl, fetch, requests, or any HTTP client). No Chromium binary, no font packages, no --no-sandbox flags.

Zero bytes. The API is a remote service accessed over HTTP. A typical Node.js container stays at 150 MB instead of the 950 MB to 1.9 GB that Puppeteer with Chromium requires.

The HTTP request will timeout or fail, just like any external API call. Use retry logic with exponential backoff in your application code. The free plan includes 200 captures per month, and paid plans scale up from there.

Yes. Set response_type=base64 to receive the screenshot as a base64-encoded string instead of writing to disk. This works in containers with no writable filesystem.

Use environment variables via docker-compose.yml, Docker secrets, or your orchestrator's secret management. Never bake the key into the Docker image. The examples on this page show the SCREENSHOTRUN_KEY environment variable pattern.

Written by

Vitalii Holben

Co-founder and CEO of ScreenshotRun. Full-stack developer building screenshot API tools that help developers capture, automate, and monitor web pages at scale. Based in Europe, shipping code since 201...

Screenshot API in a few clicks

Free plan includes 200 screenshots every month. No credit card required.

Sign up → copy your key → make your first call