Build a Screenshot-to-PDF API with Playwright and Node.js
Build a screenshot-to-PDF API with Node.js, Fastify, Playwright and a BullMQ queue: render any page to PDF, cache by options, serve downloads and bill per document.
Build a small HTML-to-PDF-as-a-service API: send a URL, get a PDF back. Playwright drives headless Chromium, BullMQ serializes renders, PostgreSQL tracks jobs and API keys, and results are cached by a hash of the render options. The stack is one Node.js application, Redis, PostgreSQL and Docker Compose. Verified 2026-09-18.
What we’re building
The finished MVP lets a client:
- Request a PDF for any public URL with page format, orientation, margins and background options.
- Get a job id immediately and download the PDF once the render finishes.
- Re-request the same URL and options and receive a cache hit instead of a new render.
- Use an API key that enforces per-key rate limits.
- Rely on SSRF protection: no private, loopback or link-local hosts are ever rendered.
This is a rendering service, not a document store. It keeps generated PDFs on disk for a TTL and deletes them afterwards; the source of truth remains the URL. Move storage to S3-compatible object storage when you need durable archives.
flowchart LR
Client --> API[Fastify API]
API --> Queue[(Redis queue)]
API --> DB[(PostgreSQL)]
Worker --> Browser[Playwright chromium]
Browser --> Disk[(PDF volume)]
Worker --> DB
API --> Disk
What you’ll learn
- How to render a page to PDF with
page.pdf()and its margin/format options. - How to run renders through a queue so the API never blocks on Chromium.
- How to cache PDFs on a hash of the URL plus render options.
- How to guard a server-side renderer against SSRF.
- How to deploy a browser-backed API behind Caddy on a small VPS.
Prerequisites
- Node.js 22.12 or newer.
- npm 10 or newer.
- Docker Engine and Compose v2.
- Redis 7 and PostgreSQL 17, supplied by Compose locally.
- A VPS with at least 2 GB RAM, 20 GB disk and a DNS record.
Project overview
The API validates the request, hashes the URL plus render options into a deterministic job key, and checks PostgreSQL for a completed job with that key. On a hit it redirects to the stored PDF. On a miss it enqueues a BullMQ job and returns 202 with the job id. A worker picks the job, renders the page with a shared Chromium instance, writes the PDF to a private volume and marks the job complete. Clients poll the job endpoint or fetch the PDF directly once it is ready.
page.pdf() only exists in headless Chromium, so the worker image is Chromium-based and the API process never imports Playwright. Keeping the API free of browser code means it starts fast and survives worker restarts.
Project structure
pdf-api/
├── src/
│ ├── app.js
│ ├── worker.js
│ ├── queue.js
│ ├── render.js
│ ├── storage.js
│ ├── ssrf.js
│ ├── db.js
│ └── keys.js
├── migrations/001_init.sql
├── Dockerfile
├── docker-compose.yml
├── Caddyfile
├── package.json
└── .env.example
1. Create the project
mkdir pdf-api && cd pdf-api
npm init -y
npm pkg set type=module # ESM — top-level await in app.js
npm i fastify @fastify/rate-limit bullmq ioredis pg playwright
npx playwright install chromium
Every dependency listed here is imported in the code below. playwright is used headless in production too — the guide does not depend on the test runner.
package.json scripts (after install):
{
"scripts": {
"start": "node --env-file=.env src/app.js",
"worker": "node --env-file=.env src/worker.js",
"dev": "node --env-file=.env --watch src/app.js"
}
}
--env-file=.env is native to Node 20.6+ (verified on Node 22). Without it, DATABASE_URL is undefined when running outside Docker. The container CMDs below bypass these scripts and receive env vars from Compose directly.
2. Environment variables
.env (project root; not committed):
DATABASE_URL=postgres://pdfapi:pdfapi@localhost:5432/pdfapi
REDIS_HOST=localhost
REDIS_PORT=6379
STORAGE_DIR=./data/pdfs
CACHE_TTL_HOURS=24
RATE_LIMIT_PER_MINUTE=30
MAX_PDF_BYTES=26214400
RENDER_TIMEOUT_MS=30000
.env.example (committed) ships with the same keys and placeholder values.
3. Database schema
migrations/001_init.sql:
CREATE TABLE IF NOT EXISTS api_keys (
key_hash TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
cache_key TEXT NOT NULL,
url TEXT NOT NULL,
options JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'rendering', 'done', 'failed')),
error TEXT,
bytes INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_cache_done
ON jobs (cache_key) WHERE status = 'done';
CREATE INDEX IF NOT EXISTS idx_jobs_status
ON jobs (status, created_at DESC);
The partial unique index is the cache: at most one done job per cache_key, while failed and queued duplicates are still allowed. CREATE UNIQUE INDEX ... WHERE requires PostgreSQL 9.6+; 17 is fine.
4. Database access
src/db.js:
import pg from 'pg';
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
});
export const query = (text, params) => pool.query(text, params);
export async function migrate() {
const { rows } = await query(
`SELECT EXISTS (
SELECT FROM information_schema.tables WHERE table_name = 'jobs'
) AS ok`
);
if (!rows[0].ok) {
const { readFile } = await import('node:fs/promises');
const sql = await readFile('migrations/001_init.sql', 'utf8');
await pool.query(sql);
}
}
export async function close() {
await pool.end();
}
The migration runs on API startup when the jobs table is missing. On production first deploy the same file is applied once by the API; later migrations follow the numbered-file pattern.
5. API key hashing
src/keys.js:
import { createHash } from 'node:crypto';
export function hashKey(rawKey) {
return createHash('sha256').update(rawKey).digest('hex');
}
Keys are stored as SHA-256 hashes, so a database leak does not expose usable keys. Provision one after the API has created the tables:
docker compose exec db psql -U pdfapi -d pdfapi \
-c "INSERT INTO api_keys (key_hash) VALUES ('$(echo -n your-key | sha256sum | cut -d' ' -f1)')"
6. SSRF guard
src/ssrf.js:
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
const PRIVATE_RE = /^(127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|::1$|fc00:|fe80:)/i;
export async function assertPublicHost(hostname) {
const addr = isIP(hostname) ? hostname : (await lookup(hostname)).address;
if (PRIVATE_RE.test(addr) || addr === '0.0.0.0') {
throw new Error(`Refusing to render private host: ${hostname} -> ${addr}`);
}
}
The renderer is a server-side browser that fetches URLs, which makes it a textbook SSRF target. The guard resolves the hostname and rejects any address in the loopback, private or link-local ranges before Chromium navigates. Extend the regex with any private range your network uses; IPv6 ULA and link-local are covered.
7. Queue
src/queue.js:
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
export const connection = new IORedis({
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT),
maxRetriesPerRequest: null,
});
export const pdfQueue = new Queue('pdf', {
connection,
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 1000,
removeOnFail: 5000,
},
});
maxRetriesPerRequest: null is required by BullMQ — with the default, blocked Redis commands time out and workers die. Retries with exponential backoff absorb transient network failures and slow sites.
8. Renderer
src/render.js:
import { chromium } from 'playwright';
import { assertPublicHost } from './ssrf.js';
let browser = null;
export async function getBrowser() {
if (browser && browser.isConnected()) return browser;
browser = await chromium.launch({
headless: true,
args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'],
});
return browser;
}
const mm = (value) => `${value}mm`;
export async function renderPdf(input) {
const target = new URL(input.url);
if (!['http:', 'https:'].includes(target.protocol)) {
throw new Error('Unsupported protocol');
}
await assertPublicHost(target.hostname);
const b = await getBrowser();
const context = await b.newContext();
try {
const page = await context.newPage();
await page.goto(input.url, {
waitUntil: input.waitUntil || 'networkidle',
timeout: Number(process.env.RENDER_TIMEOUT_MS || 30000),
});
const m = input.margin || {};
const pdf = await page.pdf({
format: input.format || 'A4',
landscape: Boolean(input.landscape),
printBackground: input.printBackground !== false,
margin: {
top: mm(m.top ?? 10),
right: mm(m.right ?? 10),
bottom: mm(m.bottom ?? 10),
left: mm(m.left ?? 10),
},
});
const buffer = Buffer.from(pdf);
const max = Number(process.env.MAX_PDF_BYTES || 26214400);
if (buffer.length > max) throw new Error(`PDF exceeds ${max} bytes`);
return buffer;
} finally {
await context.close();
}
}
export async function shutdown() {
if (browser) await browser.close();
}
page.pdf() runs only in headless Chromium and does not open a print dialog. waitUntil: 'networkidle' waits for late XHR data, which many SPAs need before printing; pages that never settle fall back to load via the request option. --no-sandbox is required in containers running as root; drop it if your image runs as a non-root user.
9. Storage
src/storage.js:
import { mkdir, writeFile, readFile } from 'node:fs/promises';
import path from 'node:path';
const ROOT = process.env.STORAGE_DIR || './data/pdfs';
function fileFor(id) {
return path.join(ROOT, id.slice(0, 2), `${id}.pdf`);
}
export async function savePdf(id, buffer) {
const file = fileFor(id);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, buffer);
return buffer.length;
}
export async function readPdf(id) {
return readFile(fileFor(id));
}
export async function deletePdf(id) {
const { unlink } = await import('node:fs/promises');
await unlink(fileFor(id)).catch(() => {});
}
Sharding by the first two characters of the id keeps directories small. The volume is private: PDFs are served only through the API, never by a static file server.
10. Worker
src/worker.js:
import { Worker } from 'bullmq';
import { connection } from './queue.js';
import { renderPdf, shutdown } from './render.js';
import { savePdf } from './storage.js';
import { query, close } from './db.js';
const worker = new Worker(
'pdf',
async (job) => {
const { id, url, options } = job.data;
await query(`UPDATE jobs SET status = 'rendering', updated_at = now() WHERE id = $1`, [id]);
try {
const buffer = await renderPdf({ url, ...options });
const bytes = await savePdf(id, buffer);
await query(
`UPDATE jobs SET status = 'done', bytes = $2, updated_at = now() WHERE id = $1`,
[id, bytes]
);
return { bytes };
} catch (err) {
await query(
`UPDATE jobs SET status = 'failed', error = $2, updated_at = now() WHERE id = $1`,
[id, err.message.slice(0, 500)]
);
throw err; // let BullMQ retry
}
},
{
connection,
concurrency: Number(process.env.WORKER_CONCURRENCY || 2),
}
);
worker.on('failed', (job, err) => {
console.error(`job ${job?.data?.id} failed: ${err.message}`);
});
process.on('SIGTERM', async () => {
await worker.close();
await shutdown();
await close();
process.exit(0);
});
console.log('pdf worker started');
Each render runs in a fresh browser context so cookies and storage never leak between customers. concurrency: 2 keeps two Chromium pages in flight — enough to saturate a small VPS without exhausting memory.
11. API
src/app.js:
import Fastify from 'fastify';
import rateLimit from '@fastify/rate-limit';
import { createHash, randomUUID } from 'node:crypto';
import { migrate, query, close } from './db.js';
import { pdfQueue } from './queue.js';
import { hashKey } from './keys.js';
import { readPdf } from './storage.js';
await migrate();
const app = Fastify({ logger: true });
const FORMATS = new Set(['A4', 'A3', 'A5', 'Letter', 'Legal', 'Tabloid']);
const MAX_MARGIN = 100;
function cacheKeyFor(url, options) {
return createHash('sha256').update(JSON.stringify({ url, options })).digest('hex');
}
function validateBody(body) {
const { url, format, landscape, printBackground, margin } = body ?? {};
if (!url || !/^https?:\/\//.test(url)) {
return { error: 'url must be an http(s) URL' };
}
if (format && !FORMATS.has(format)) {
return { error: `format must be one of: ${[...FORMATS].join(', ')}` };
}
if (margin) {
for (const side of ['top', 'right', 'bottom', 'left']) {
const v = margin[side];
if (v !== undefined && (!Number.isFinite(v) || v < 0 || v > MAX_MARGIN)) {
return { error: `margin.${side} must be between 0 and ${MAX_MARGIN}` };
}
}
}
return {
value: {
url,
format: format || 'A4',
landscape: Boolean(landscape),
printBackground: printBackground !== false,
margin: margin || {},
},
};
}
await app.register(rateLimit, {
max: Number(process.env.RATE_LIMIT_PER_MINUTE || 30),
timeWindow: '1 minute',
keyGenerator: (req) => req.headers['x-api-key'] || req.ip,
});
app.addHook('onRequest', async (req, reply) => {
if (req.url === '/health') return;
const key = req.headers['x-api-key'];
if (!key) return reply.code(401).send({ error: 'Missing x-api-key' });
const { rowCount } = await query(`SELECT 1 FROM api_keys WHERE key_hash = $1`, [hashKey(key)]);
if (!rowCount) return reply.code(401).send({ error: 'Invalid API key' });
});
app.get('/health', async () => ({ status: 'ok' }));
app.post('/pdf', async (req, reply) => {
const parsed = validateBody(req.body);
if (parsed.error) return reply.code(400).send({ error: parsed.error });
const { url, ...options } = parsed.value;
const cacheKey = cacheKeyFor(url, options);
const { rows: cached } = await query(
`SELECT id FROM jobs WHERE cache_key = $1 AND status = 'done' LIMIT 1`,
[cacheKey]
);
if (cached.length) {
return reply.code(200).send({ id: cached[0].id, cached: true, pdf: `/jobs/${cached[0].id}/pdf` });
}
const id = randomUUID();
try {
await query(
`INSERT INTO jobs (id, cache_key, url, options) VALUES ($1, $2, $3, $4)`,
[id, cacheKey, url, JSON.stringify(options)]
);
} catch (err) {
req.log.error({ err: err.message }, 'job insert failed');
return reply.code(500).send({ error: 'Could not create job' });
}
await pdfQueue.add('render', { id, url, options });
return reply.code(202).send({ id, cached: false, status: `/jobs/${id}` });
});
app.get('/jobs/:id', async (req, reply) => {
const { rows } = await query(
`SELECT id, status, error, bytes, created_at, updated_at FROM jobs WHERE id = $1`,
[req.params.id]
);
if (!rows.length) return reply.code(404).send({ error: 'Job not found' });
return rows[0];
});
app.get('/jobs/:id/pdf', async (req, reply) => {
const { rows } = await query(`SELECT status FROM jobs WHERE id = $1`, [req.params.id]);
if (!rows.length) return reply.code(404).send({ error: 'Job not found' });
if (rows[0].status !== 'done') {
return reply.code(409).send({ error: `Job is ${rows[0].status}` });
}
let buffer;
try {
buffer = await readPdf(req.params.id);
} catch {
return reply.code(410).send({ error: 'PDF expired' });
}
reply.header('Content-Type', 'application/pdf');
reply.header('Content-Disposition', `inline; filename="${req.params.id}.pdf"`);
return reply.send(buffer);
});
app.delete('/jobs/:id', async (req) => {
await query(`DELETE FROM jobs WHERE id = $1`, [req.params.id]);
return { deleted: true };
});
const port = Number(process.env.PORT || 3000);
app.listen({ port, host: '0.0.0.0' }).then((addr) => app.log.info(`listening on ${addr}`));
process.on('SIGTERM', async () => { await app.close(); await close(); });
Every handler validates input and returns a JSON error with a status code. The cache check runs before enqueue, so repeated requests skip Chromium entirely. The PDF endpoint distinguishes 404 (unknown job), 409 (not ready) and 410 (file deleted) so clients can act on each case.
12. Running locally
docker compose up -d redis db
npm run start & # API on :3000 (loads .env via --env-file)
npm run worker & # render worker
# Register a key after the API has logged "listening on"
docker compose exec db psql -U pdfapi -d pdfapi \
-c "INSERT INTO api_keys (key_hash) VALUES ('$(echo -n your-key | sha256sum | cut -d' ' -f1)')"
curl -s -X POST http://localhost:3000/pdf \
-H "x-api-key: your-key" -H "content-type: application/json" \
-d '{"url":"https://example.com","format":"A4","landscape":false}'
# → {"id":"<uuid>","cached":false,"status":"/jobs/<uuid>"}
curl -s http://localhost:3000/jobs/<uuid> -H "x-api-key: your-key"
# → {"status":"done","bytes":48321,...}
curl -sL http://localhost:3000/jobs/<uuid>/pdf -H "x-api-key: your-key" -o out.pdf
The second identical request returns "cached":true and skips the queue.
13. Docker setup
Dockerfile (Playwright needs its official browser base image):
FROM mcr.microsoft.com/playwright:v1.49.0-noble
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
CMD ["node", "src/app.js"]
docker-compose.yml:
services:
redis:
image: redis:7
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
retries: 10
volumes:
- redis-data:/data
db:
image: postgres:17
environment:
POSTGRES_USER: pdfapi
POSTGRES_PASSWORD: pdfapi
POSTGRES_DB: pdfapi
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pdfapi"]
interval: 5s
retries: 10
api:
build: .
command: ["node", "src/app.js"]
environment:
DATABASE_URL: postgres://pdfapi:pdfapi@db:5432/pdfapi
REDIS_HOST: redis
REDIS_PORT: "6379"
STORAGE_DIR: /data/pdfs
CACHE_TTL_HOURS: "24"
RATE_LIMIT_PER_MINUTE: "30"
volumes:
- pdf-data:/data/pdfs
ports:
- "3000:3000"
depends_on:
redis: { condition: service_healthy }
db: { condition: service_healthy }
restart: unless-stopped
worker:
build: .
command: ["node", "src/worker.js"]
environment:
DATABASE_URL: postgres://pdfapi:pdfapi@db:5432/pdfapi
REDIS_HOST: redis
REDIS_PORT: "6379"
STORAGE_DIR: /data/pdfs
RENDER_TIMEOUT_MS: "30000"
WORKER_CONCURRENCY: "2"
volumes:
- pdf-data:/data/pdfs
shm_size: 512mb
depends_on:
redis: { condition: service_healthy }
db: { condition: service_healthy }
restart: unless-stopped
volumes:
redis-data:
pgdata:
pdf-data:
The same image runs both processes. Both mount the same pdf-data volume because the API reads what the worker writes. shm_size: 512mb gives Chromium shared memory; the browser image is ~1.7 GB, so budget disk accordingly.
14. Production deployment
git clone https://github.com/yourname/pdf-api.git
cd pdf-api
cp .env.example .env
docker compose up -d --build
Caddy in front for HTTPS:
pdf.your-domain.com {
reverse_proxy api:3000
}
Move to /etc/caddy/Caddyfile, sudo systemctl reload caddy. Certificates are automatic. Add a retention cron to delete expired PDFs and their rows:
0 4 * * * docker compose exec -T api node -e "
import('./src/db.js').then(async ({ query, close }) => {
const ttl = process.env.CACHE_TTL_HOURS || 24;
const { rows } = await query(\"DELETE FROM jobs WHERE created_at < now() - ($1 || ' hours')::interval RETURNING id\", [ttl]);
const { deletePdf } = await import('./src/storage.js');
for (const r of rows) await deletePdf(r.id);
await close();
})"
15. Recommended infrastructure
| Workload | VPS | Why |
|---|---|---|
| ≤20k PDFs/month | 2 GB / 2 vCPU (Hetzner CX22, ~$4.5/mo) | Queue + 1 concurrency |
| ≤100k PDFs/month | 4 GB / 2 vCPU (Hetzner CX32, ~$8.5/mo) | WORKER_CONCURRENCY=2 |
| >100k PDFs/month | 8 GB / 4 vCPU | Second worker container, raise concurrency |
Rendering is CPU- and RAM-bound: each Chromium page costs ~150–250 MB during the render and a few hundred milliseconds of CPU per page. The 2 GB tier is comfortable at one concurrent render with a queue smoothing bursts.
Cost
Assumptions: 20,000 PDFs/month, 200 KB average output, 24h cache, 80% cache hit rate.
| Service | Monthly cost |
|---|---|
| VPS 2 GB (Hetzner CX22) | $4.51 |
| Bandwidth (~4 GB out) | $0.00 |
| Domain | $1.00 |
| Total | ≈ $5.51/month |
Disk is negligible at these sizes: 20,000 × 200 KB × 0.2 (misses only) ≈ 800 MB before the TTL sweeps it. Object storage only becomes worth it past ~1M renders/month or when you need durable archives.
Production checklist
- HTTPS via Caddy (automatic Let’s Encrypt)
- API keys issued per customer, stored hashed
- Rate limiting per key, not per IP
- SSRF guard enabled in
renderPdf(rejects private/loopback/link-local) -
shm_sizeset on the worker (Chromium shared memory) - Retention cron deleting PDFs and old rows
- PostgreSQL volume backed up (
pg_dump) - Health endpoint monitored (
/health) -
restart: unless-stoppedon all services -
--no-sandboxdropped if the container runs as non-root
Environment variables
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
yes | PostgreSQL connection string |
REDIS_HOST |
yes | Redis hostname (redis in Compose) |
REDIS_PORT |
yes | Redis port (6379) |
STORAGE_DIR |
no | PDF directory, default ./data/pdfs |
CACHE_TTL_HOURS |
no | Cache/retention TTL, default 24 |
RATE_LIMIT_PER_MINUTE |
no | Requests per key per minute, default 30 |
RENDER_TIMEOUT_MS |
no | Playwright navigation timeout, default 30000 |
MAX_PDF_BYTES |
no | Reject larger PDFs, default 25 MB |
WORKER_CONCURRENCY |
no | Concurrent renders, default 2 |
PORT |
no | API listen port, default 3000 |
Common problems
npm cifails in Docker with EUSAGE: the build needspackage-lock.json. It exists right after thenpm iin step 1 — commit it. If you switch tonpm install, the build becomes non-reproducible and image caching breaks.page.pdf()throws “Protocol error” or hangs:page.pdf()only works in headless Chromium. Do not switchheadlesstofalseingetBrowser(), and do not run the renderer on Firefox or WebKit builds.- Chromium runs out of shared memory:
--disable-dev-shm-usageis set, butshm_sizein Compose is the real fix. Without it, concurrent renders can crash the worker. - Empty or truncated PDFs on JS-heavy pages:
networkidletimed out and the print happened early. RaiseRENDER_TIMEOUT_MS, or pass"waitUntil":"load"for pages that stream indefinitely. - Duplicate renders under load: the
idx_jobs_cache_donepartial index allows queued duplicates, and the cache check runs before insert. Two simultaneous identical requests can both enqueue; the second render is discarded when itsUPDATEviolates the unique index. Accept the occasional duplicate rather than serializing the API. - PDF grows without bound on huge pages: enforce
MAX_PDF_BYTES(the worker already rejects oversized output), and preferprintBackground: falsefor text-heavy documents.
Improvements
- Header/footer templates with page numbers (
displayHeaderFooterplusheaderTemplate/footerTemplate). - A
POST /pdfvariant that accepts raw HTML instead of a URL, rendered viapage.setContent. - Cookie/
Authorizationheader injection for authenticated pages, gated behind a per-customer allowlist. - Webhook callback on completion, so clients do not poll.
- Signed download URLs served by object storage (R2/S3) with short expiry, removing the shared volume.
Conclusion
A screenshot-to-PDF API is a thin service over page.pdf() plus a queue. Fastify validates and caches requests, BullMQ keeps Chromium off the request path, and a private volume serves the results. The whole thing runs on a $5 VPS and scales by raising worker concurrency — reproducible from this guide in an afternoon.