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-sandboxdisables 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"]
Problems:
- Bloated image — Final image is 800MB+ (alpine only saves so much)
- Long builds — 5+ minutes to install deps and compile
-
Security risk — Running
--no-sandboxin containers is dangerous - Memory limits — Each container needs 512MB+ allocated
- 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"]
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'}`);
});
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"
}
}
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"]
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
Run it:
export PAGEBOLT_API_KEY=your_api_key_here
docker-compose up --build
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
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
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
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}`);
}
Key Takeaways
- No system dependencies — Skip font installation, library compilation
- Lean images — 200MB vs 800MB = faster deploys and pulls
- Easy scaling — Stateless HTTP calls scale horizontally in K8s
- Better security — No sandbox issues; no containers running privileged
- 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)