reportgen
Back to all posts

Build Your Own PDF API, Part 6: Docker, Hosting and Scaling a PDF Service

November 2, 2026

Part 6 of 6 of Build Your Own ReportGen.

You have an API, a worker, a browser pool and a locked-down network layer. The last step is getting it running somewhere reliable. Chrome makes containers slightly trickier than a normal Node app, so let's go through it.


The Dockerfile

FROM node:22-slim
 
WORKDIR /app
 
# Fonts. A slim image has almost none, so text falls back to whatever exists
# and CJK, Arabic or emoji characters render as empty boxes.
RUN apt-get update && apt-get install -y --no-install-recommends \
      fonts-liberation fonts-noto-core fonts-noto-cjk fonts-noto-color-emoji \
    && rm -rf /var/lib/apt/lists/*
 
# Install Chrome where the non-root user can read it.
ENV PUPPETEER_CACHE_DIR=/app/.cache/puppeteer
 
COPY package*.json ./
RUN npm ci --omit=dev \
    # Downloads the Chrome version this Puppeteer release was tested with,
    # plus the system libraries it needs (needs root, so do it before USER).
    && npx puppeteer browsers install chrome --install-deps \
    && rm -rf /var/lib/apt/lists/*
 
COPY . .
 
# Don't run a browser that loads untrusted HTML as root.
RUN useradd --create-home --uid 10001 pdf && chown -R pdf:pdf /app
USER pdf
 
CMD ["node", "worker.js"]

Build one image and run it twice with different commands: node api.js for the API and node worker.js for workers.

Why this shape

Let Puppeteer install its own Chrome. Each Puppeteer release is tested against one specific Chrome version. Installing chromium from the OS package manager often gives you a different version, and mismatches cause confusing protocol errors after upgrades. npx puppeteer browsers install chrome --install-deps gets the right version and its system libraries in one step.

Install fonts on purpose. This is the number one "works on my machine" PDF bug. Your laptop has dozens of fonts; node:22-slim has almost none. Add the Noto families for any languages your users write in. And as Part 2 says, load your brand fonts explicitly in templates rather than relying on the system.

Run as a non-root user. If a malicious template ever escapes Chrome through a browser bug, it lands as an unprivileged user instead of root. Combined with Part 5's network rules, that limits the damage a lot.

About --no-sandbox

Chrome's own sandbox needs kernel features (user namespaces, seccomp) that many container runtimes don't allow by default. That's why --no-sandbox appears in almost every Puppeteer Docker guide, including the code in this series.

Your options, from best to most common:

  1. Keep Chrome's sandbox by running the container with a seccomp profile that allows it (see Puppeteer's troubleshooting guide). Then drop --no-sandbox from the launch args.
  2. Use --no-sandbox, but compensate: non-root user, no secrets in the worker's environment that it doesn't need, strict egress rules, and a container runtime with its own isolation (most managed platforms run containers in microVMs or gVisor).

Option 2 is a reasonable trade for templates you mostly control. If you render HTML from anonymous users, aim for option 1.

/dev/shm

Docker gives containers 64MB of shared memory by default, and Chrome crashes when it fills. Either keep --disable-dev-shm-usage in the launch args (used throughout this series), or raise it with docker run --shm-size=1gb.


Where to Host It

The worker is a long-running process that needs plenty of RAM. That rules out some options and makes others easy.

Option Good fit when Watch out for
One VPS (Hetzner, DigitalOcean...) with Docker Compose Steady, predictable load and a tight budget You handle OS updates, restarts and backups
Container platforms (Railway, Render, Fly.io...) You want push-to-deploy and easy scaling Memory pricing; pick plans by RAM, not CPU
Kubernetes You already run a cluster Pure overhead if you don't
Serverless (Lambda, Cloud Run) Very spiky or very low volume Cold starts with Chrome are slow; package size limits

reportgen.io ran on a single VPS with Kubernetes for a long time and later moved to a container platform. For your own service, start with the simplest thing your team already knows how to operate.

A minimal Compose file for a single machine:

services:
  api:
    build: .
    command: ["node", "api.js"]
    ports: ["3000:3000"]
    env_file: .env
    restart: unless-stopped
  worker:
    build: .
    command: ["node", "worker.js"]
    env_file: .env
    restart: unless-stopped
    deploy:
      replicas: 2
      resources:
        limits:
          memory: 1g

Sizing and Scaling

Size workers by memory

Pick a memory limit per worker container, then set the pool's concurrency so your heaviest real templates fit inside it with headroom. If a worker gets killed for running out of memory, lower concurrency before you raise the memory limit. It's usually one huge template causing it.

Scale workers, not the API

The API only inserts rows, so it rarely needs more than one or two small instances. All the heavy lifting is in workers, and thanks to SKIP LOCKED (Part 3) you can add or remove them freely.

Scale on queue depth

CPU is a bad scaling signal for PDF workers because they spend a lot of time waiting on page loads. Scale on what the user feels instead:

-- How many jobs are waiting, and how long has the oldest one waited?
SELECT count(*) AS queued,
       extract(epoch FROM now() - min(run_after)) AS oldest_wait_seconds
FROM pdf_jobs
WHERE status = 'queued' AND run_after <= now();

Add a worker when the oldest job has waited longer than your target (say, 10 seconds). Remove one when the queue has been empty for a while. Most platforms can scale on a custom metric, or a small cron script can call their API.

Deploys without lost jobs

The worker from Part 3 finishes in-progress jobs on SIGTERM, and the reaper re-queues anything a crashed worker left behind. Make sure your platform's shutdown grace period is longer than your slowest render, so workers get time to finish.


Production Checklist

  • Image installs Chrome via Puppeteer, plus fonts for your users' languages
  • Container runs as a non-root user
  • /dev/shm handled (--disable-dev-shm-usage or --shm-size)
  • Worker memory limit set; pool concurrency tuned to fit it
  • Shutdown grace period longer than the slowest render
  • Workers can't reach private networks or cloud metadata (Part 5)
  • Bucket lifecycle rule deletes old PDFs
  • Alerts on queue wait time, failure rate and worker restarts
  • Error tracking (Sentry or similar) on both API and workers
  • A health check: API answers /health; workers are alive if they've claimed or polled recently

That's It

You now have everything reportgen.io did for its users, minus the billing:

  1. A minimal service: HTML in, PDF out.
  2. Templates with page numbers, page breaks and fonts that load.
  3. Async jobs on Postgres with retries, crash recovery, storage and webhooks.
  4. A browser pool that caps memory, recycles Chrome and survives crashes.
  5. Security that keeps templates from reaching your internal network.
  6. A container and a scaling plan (this post).

Thanks to everyone who used reportgen.io over the years. We hope this helps you build something better.


Frequently Asked Questions

Can I run the API and worker in one process? Yes, for small setups. Start both from one entry point. You lose the ability to scale them separately, which is fine until it isn't.

Should the worker run on ARM machines? Puppeteer's bundled Chrome has historically had limited Linux ARM64 support. Check the current Puppeteer docs before choosing ARM instances, or build on x86_64 to be safe.

How do I upgrade Chrome? Upgrade the puppeteer package and rebuild the image. Render a set of reference templates before and after, and compare the PDFs. Chrome upgrades occasionally shift layout by a pixel or two.