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();
  })"
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_size set 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-stopped on all services
  • --no-sandbox dropped 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 ci fails in Docker with EUSAGE: the build needs package-lock.json. It exists right after the npm i in step 1 — commit it. If you switch to npm 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 switch headless to false in getBrowser(), and do not run the renderer on Firefox or WebKit builds.
  • Chromium runs out of shared memory: --disable-dev-shm-usage is set, but shm_size in Compose is the real fix. Without it, concurrent renders can crash the worker.
  • Empty or truncated PDFs on JS-heavy pages: networkidle timed out and the print happened early. Raise RENDER_TIMEOUT_MS, or pass "waitUntil":"load" for pages that stream indefinitely.
  • Duplicate renders under load: the idx_jobs_cache_done partial index allows queued duplicates, and the cache check runs before insert. Two simultaneous identical requests can both enqueue; the second render is discarded when its UPDATE violates 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 prefer printBackground: false for text-heavy documents.

Improvements

  • Header/footer templates with page numbers (displayHeaderFooter plus headerTemplate/footerTemplate).
  • A POST /pdf variant that accepts raw HTML instead of a URL, rendered via page.setContent.
  • Cookie/Authorization header 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.

Related guides