From Express endpoint to Docker container — the full build.
Want to build your own screenshot service? This tutorial walks through the complete build — an Express server with Playwright, browser pool management, request validation, Docker containerisation, and health checks. By the end, you'll have a working microservice. You'll also understand exactly why you might not want to run it yourself.
mkdir screenshot-service && cd screenshot-service
npm init -y
npm install express playwright
npx playwright install chromium --with-deps
import express from 'express';
import { chromium } from 'playwright';
const app = express();
app.use(express.json());
let browser;
async function getBrowser() {
if (!browser || !browser.isConnected()) {
browser = await chromium.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
}
return browser;
}
app.post('/screenshot', async (req, res) => {
const { url, width = 1280, height = 720, fullPage = false, format = 'png' } = req.body;
if (!url) return res.status(400).json({ error: 'url is required' });
try {
const browser = await getBrowser();
const page = await browser.newPage({
viewport: { width, height },
});
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
const buffer = await page.screenshot({ fullPage, type: format });
await page.close();
res.set('Content-Type', `image/${format}`);
res.send(buffer);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/health', (req, res) => {
res.json({ status: 'ok', browser: browser?.isConnected() ?? false });
});
app.listen(3000, () => console.log('Screenshot service on :3000'));
Don't trust user input. Validate URLs (reject internal IPs, non-HTTP protocols), clamp dimensions (max 4096px width/height), validate format (only 'png' or 'jpeg'), and set a request timeout.
function validateRequest(body) {
const errors = [];
if (!body.url) errors.push('url is required');
if (body.url && !body.url.match(/^https?:\/\//)) errors.push('url must start with http(s)://');
if (body.width && (body.width < 100 || body.width > 4096)) errors.push('width must be 100-4096');
if (body.height && (body.height < 100 || body.height > 4096)) errors.push('height must be 100-4096');
if (body.format && !['png', 'jpeg'].includes(body.format)) errors.push('format must be png or jpeg');
return errors;
}
A single browser instance handles multiple concurrent screenshots (each in its own page). But under heavy load, you want multiple browser instances. A simple pool:
class BrowserPool {
constructor(size = 3) {
this.size = size;
this.browsers = [];
this.current = 0;
}
async init() {
for (let i = 0; i < this.size; i++) {
const browser = await chromium.launch({ args: ['--no-sandbox'] });
this.browsers.push(browser);
}
}
get() {
const browser = this.browsers[this.current];
this.current = (this.current + 1) % this.size;
return browser;
}
async close() {
for (const browser of this.browsers) {
await browser.close();
}
}
}
Round-robin distributes load across browsers. Each browser uses ~300MB RAM, so on a 4GB server, 3 browsers is a safe pool size.
FROM mcr.microsoft.com/playwright:v1.50.0-noble
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
The official Playwright Docker image includes all system dependencies (fonts, libraries) that Chromium needs. Don't try to install Playwright on a minimal Alpine image — you'll spend hours chasing missing shared libraries.
This tutorial gives you a working service. For production, you also need: memory monitoring and browser restart on leak, SSRF protection (block requests to internal IPs), rate limiting, authentication, process crash recovery, log rotation, and security updates (Chromium vulnerabilities are frequent).
If you'd rather just take screenshots without managing browser infrastructure, nightglass is for you — half a cent per screenshot. See the pricing comparison to check build-vs-buy at your volume. To scale this service further, the scale architecture guide covers queue design. Note: this service won't work in serverless — see why Lambda and Playwright don't mix.