DEV Community

Custodia-Admin
Custodia-Admin

Posted on

How to Take Screenshots in Docker (Without Installing a Browser)

How to Take Screenshots in Docker (Without Installing a Browser)

You're building a containerized app. It needs to generate screenshots. You add Puppeteer to your Node.js image and... your Docker image balloons from 200MB to 800MB+.

Why?

Puppeteer in Docker requires:

  • System dependencies — fonts, libraries, shared objects (libX11, libXcomposite, etc.)
  • Chromium binary — 200–300MB alone
  • Security concerns--no-sandbox disables Linux sandboxing; risky in production
  • Memory overhead — Each container needs 256MB+ for Puppeteer + Chromium
  • Build time — Installing deps and Chromium adds 2–5 minutes to builds
  • Layer bloat — Intermediate layers make images impossible to cache efficiently

Self-hosted Puppeteer in Docker is slow to build, large to deploy, and risky to run.

There's a simpler pattern: replace Puppeteer with an API call.

One HTTP request. Screenshot back in 1–2 seconds. No browser. No dependencies. No sandbox issues.

Here's how to take screenshots in Docker without managing a browser.

The Problem: Puppeteer in Docker Is Bloated & Fragile

A typical Dockerfile with Puppeteer:

# Puppeteer in Docker: bloated and slow to build
FROM node:18-alpine

# Install system dependencies for Chromium
RUN apk add --no-cache \
  chromium \
  font-noto \
  font-noto-cjk \
  liberation-fonts \
  freetype \
  harfbuzz \
  nss \
  ca-certificates

WORKDIR /app
COPY package.json package-lock.json .
RUN npm ci

# Copy app code
COPY . .

# Port, entrypoint, etc.
EXPOSE 3000
CMD ["node", "app.js"]
Enter fullscreen mode Exit fullscreen mode

Problems:

  1. Bloated image — Final image is 800MB+ (alpine only saves so much)
  2. Long builds — 5+ minutes to install deps and compile
  3. Security risk — Running --no-sandbox in containers is dangerous
  4. Memory limits — Each container needs 512MB+ allocated
  5. Fragility — Chromium crashes in some container environments

The Solution: Screenshot API Instead

Replace Puppeteer with a simple HTTP call. Your Dockerfile:

# API-based screenshot: lean and fast
FROM node:18-alpine

WORKDIR /app
COPY package.json package-lock.json .
RUN npm ci --only=production

COPY . .

EXPOSE 3000
CMD ["node", "app.js"]
Enter fullscreen mode Exit fullscreen mode

Done. No system dependencies. No Chromium. 200MB image.

Complete Example: Express App with Screenshot API

// app.js
const express = require('express');
const https = require('https');
const fs = require('fs');

const app = express();
const PORT = process.env.PORT || 3000;
const API_KEY = process.env.PAGEBOLT_API_KEY;

app.use(express.json());

/**
 * POST /screenshot
 * Body: { url: "https://example.com" }
 * Returns: PNG binary
 */
app.post('/screenshot', async (req, res) => {
  const { url } = req.body;

  if (!url) {
    return res.status(400).json({ error: 'url is required' });
  }

  if (!API_KEY) {
    return res.status(500).json({ error: 'PAGEBOLT_API_KEY not set' });
  }

  try {
    const screenshot = await takeScreenshot(url);

    res.setHeader('Content-Type', 'image/png');
    res.setHeader('Content-Length', screenshot.length);
    res.send(screenshot);
  } catch (error) {
    console.error('Screenshot error:', error.message);
    res.status(500).json({ error: error.message });
  }
});

/**
 * Health check
 */
app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

/**
 * Take screenshot via PageBolt API
 */
function takeScreenshot(url) {
  return new Promise((resolve, reject) => {
    const payload = JSON.stringify({
      url: url,
      format: 'png',
      width: 1280,
      height: 720,
      fullPage: false,
      blockAds: true,
      blockBanners: true
    });

    const options = {
      hostname: 'api.pagebolt.dev',
      path: '/v1/screenshot',
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${API_KEY}`,
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(payload)
      },
      timeout: 30000
    };

    const req = https.request(options, (res) => {
      let data = Buffer.alloc(0);

      res.on('data', (chunk) => {
        data = Buffer.concat([data, chunk]);
      });

      res.on('end', () => {
        if (res.statusCode === 200) {
          resolve(data);
        } else {
          reject(new Error(`API error ${res.statusCode}`));
        }
      });
    });

    req.on('error', reject);
    req.on('timeout', () => {
      req.destroy();
      reject(new Error('Request timeout'));
    });

    req.write(payload);
    req.end();
  });
}

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
  console.log(`API key configured: ${API_KEY ? 'yes' : 'no'}`);
});
Enter fullscreen mode Exit fullscreen mode

package.json (minimal dependencies):

{
  "name": "screenshot-api-docker",
  "version": "1.0.0",
  "main": "app.js",
  "dependencies": {
    "express": "^4.18.2"
  },
  "scripts": {
    "start": "node app.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

Dockerfile:

FROM node:18-alpine

WORKDIR /app
COPY package.json package-lock.json .
RUN npm ci --only=production

COPY app.js .

EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"

CMD ["node", "app.js"]
Enter fullscreen mode Exit fullscreen mode

Docker Compose Setup

# docker-compose.yml
version: '3.8'

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      PAGEBOLT_API_KEY: ${PAGEBOLT_API_KEY}
      NODE_ENV: production
    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"]
      interval: 30s
      timeout: 3s
      retries: 3
Enter fullscreen mode Exit fullscreen mode

Run it:

export PAGEBOLT_API_KEY=your_api_key_here
docker-compose up --build
Enter fullscreen mode Exit fullscreen mode

Test it:

curl -X POST http://localhost:3000/screenshot \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com"}' \
  > screenshot.png

# View the screenshot
open screenshot.png  # macOS
# or display screenshot.png  # Linux
Enter fullscreen mode Exit fullscreen mode

Size & Performance Comparison

Metric Puppeteer in Docker API-based
Image size 800MB+ 200MB
Build time 5–10 min 30 sec
Container memory 512MB+ 128MB
Response time 3–5 sec 1–2 sec
Cold start Slow (browser launch) Instant
Scaling Hard (memory intensive) Easy (stateless)

Cost: 1,000 screenshots/month = 5–10x cheaper with the API.

Kubernetes Deployment

# screenshot-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: screenshot-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: screenshot-api
  template:
    metadata:
      labels:
        app: screenshot-api
    spec:
      containers:
      - name: screenshot-api
        image: screenshot-api:latest
        ports:
        - containerPort: 3000
        env:
        - name: PAGEBOLT_API_KEY
          valueFrom:
            secretKeyRef:
              name: pagebolt-secrets
              key: api-key
        resources:
          requests:
            memory: "128Mi"
            cpu: "100m"
          limits:
            memory: "256Mi"
            cpu: "500m"
        livenessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 10
          periodSeconds: 10
Enter fullscreen mode Exit fullscreen mode

Deploy:

# Create secret
kubectl create secret generic pagebolt-secrets \
  --from-literal=api-key=YOUR_API_KEY

# Deploy
kubectl apply -f screenshot-deployment.yaml

# Expose via service
kubectl expose deployment screenshot-api \
  --type=LoadBalancer \
  --port=80 \
  --target-port=3000
Enter fullscreen mode Exit fullscreen mode

Error Handling & Retries in Containers

async function takeScreenshotWithRetry(url, maxRetries = 3) {
  let lastError;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      console.log(`Screenshot attempt ${attempt + 1}/${maxRetries} for ${url}`);
      return await takeScreenshot(url);
    } catch (error) {
      lastError = error;
      console.error(`Attempt ${attempt + 1} failed:`, error.message);

      if (attempt < maxRetries - 1) {
        const delay = (attempt + 1) * 1000;
        console.log(`Retrying in ${delay}ms...`);
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }

  throw new Error(`Failed after ${maxRetries} attempts: ${lastError.message}`);
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. No system dependencies — Skip font installation, library compilation
  2. Lean images — 200MB vs 800MB = faster deploys and pulls
  3. Easy scaling — Stateless HTTP calls scale horizontally in K8s
  4. Better security — No sandbox issues; no containers running privileged
  5. Lower cost — API charges beat container CPU costs at scale

Next step: Get your free API key at pagebolt.dev. 100 requests/month, no credit card required.

Try it free: https://pagebolt.dev/pricing

Top comments (0)