PDFPipe

Guide

HTML to PDF in Docker

Adding PDF generation to a Dockerized app usually means one of: bundling Chromium or Puppeteer into the image (200 MB+, slow pulls, glibc issues on Alpine), installing wkhtmltopdf (outdated engine, font rendering gaps), or running a separate Gotenberg container to manage. PDFPipe is a single HTTP call to a remote renderer. The Dockerfile stays small, Alpine images work without modification, and there is nothing to maintain inside the container.

The image size problem

ApproachAdded image sizeAlpine compatibleExtra maintenance
Chromium + apt~200 MBNo (glibc required)Yes (browser updates, flags)
puppeteer + chrome~350 MBNoYes (npm + binary)
wkhtmltopdf binary~80 MBNoYes (outdated engine)
Gotenberg sidecar+whole containerSeparate compose serviceYes (version, config)
PDFPipe API0 bytesYesNo

Before: Dockerfile with Chromium

Dockerfile (before)
# What most teams end up with when bundling a browser
FROM node:22-bookworm

# 200 MB+ of Chromium, fonts, and shared libraries
RUN apt-get update && apt-get install -y \
    chromium \
    fonts-liberation \
    libnss3 libasound2 libatk1.0-0 \
    libcups2 libdbus-1-3 libdrm2 \
    libgbm1 libglib2.0-0 libgtk-3-0 \
    libxcomposite1 libxdamage1 libxfixes3 \
    libxkbcommon0 libxrandr2 libxss1 \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

ENV PUPPETEER_SKIP_DOWNLOAD=true \
    PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium

EXPOSE 3000
CMD ["node", "server.js"]

After: no browser in the image

Dockerfile (after)
# With PDFPipe: the PDF renderer is remote. No browser in the image.
FROM node:22-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

EXPOSE 3000
CMD ["node", "server.js"]

The image shrinks by roughly 200 MB, build times drop, and Alpine base images work out of the box. The API key is injected at runtime, never baked into the image.

Node.js / Express example

server.js
// server.js  (any Node/Express app)
import express from "express";
import Handlebars from "handlebars";
import { readFileSync } from "fs";

const app = express();
app.use(express.json());

const invoiceTmpl = Handlebars.compile(readFileSync("./templates/invoice.html", "utf-8"));

app.post("/invoices/:id/pdf", async (req, res) => {
  const html = invoiceTmpl(req.body);

  const upstream = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.PDFPIPE_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ html, options: { format: "A4" } }),
  });

  if (!upstream.ok) {
    const err = await upstream.json().catch(() => ({}));
    return res.status(502).json({ error: "PDF generation failed", detail: err });
  }

  res.set({
    "Content-Type": "application/pdf",
    "Content-Disposition": `attachment; filename="invoice-${req.params.id}.pdf"`,
  });
  upstream.body.pipeTo(new WritableStream({
    write(chunk) { res.write(chunk); },
    close() { res.end(); },
  }));
});

app.listen(3000, () => console.log("ready on :3000"));

Python / FastAPI example (Alpine image)

Dockerfile
# Alpine images work without modification.
# No glibc workaround, no --no-sandbox flag, no shared library hunting.
FROM python:3.12-alpine

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
main.py
# main.py (FastAPI)
import httpx
from fastapi import FastAPI
from fastapi.responses import Response

app = FastAPI()

@app.post("/invoices/{invoice_id}/pdf")
async def invoice_pdf(invoice_id: str):
    html = build_invoice_html(invoice_id)  # your template rendering

    async with httpx.AsyncClient() as client:
        r = await client.post(
            "https://api.pdfpipe.xyz/v1/pdf",
            headers={"Authorization": f"Bearer {settings.PDFPIPE_API_KEY}"},
            json={"html": html, "options": {"format": "A4"}},
            timeout=60.0,
        )
    r.raise_for_status()
    return Response(r.content, media_type="application/pdf")

docker-compose

Pass the API key as an environment variable. Docker Compose reads it from .env at startup and injects it into the container, keeping it out of version control.

docker-compose.yml
# docker-compose.yml

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - PDFPIPE_API_KEY=${PDFPIPE_API_KEY}
    # PDFPIPE_API_KEY never appears in the image — only at runtime.
.env
# .env  (not committed to git)
PDFPIPE_API_KEY=pp_live_your_key

Common questions

Does this work in a scratch or distroless image?

Yes. PDFPipe is a remote HTTP call, so the only requirement is that your container has network access to api.pdfpipe.xyz. No shared libraries, no fonts, no display server needed.

What about offline or air-gapped environments?

PDFPipe requires internet access to the render API. For fully air-gapped environments, Gotenberg (self-hosted Chromium) or wkhtmltopdf are the alternative, with the trade-offs noted in the table above.

Can I stream the PDF directly to the client?

Yes. The API returns raw bytes. Pipe the response body directly to your outgoing HTTP response. The Express example above shows this using the Streams API. In Node.js you can also use upstream.body.pipe(res) with thenode-fetch stream interface.

How do I set a PDF file name for the download prompt?

Set the Content-Disposition: attachment; filename="invoice.pdf" header on your own response. The browser uses that as the save-as filename. The API does not set this for you since the filename is application context.

Get started

500 renders a month free. Key issued instantly, no card needed.