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
| Approach | Added image size | Alpine compatible | Extra maintenance |
|---|---|---|---|
| Chromium + apt | ~200 MB | No (glibc required) | Yes (browser updates, flags) |
| puppeteer + chrome | ~350 MB | No | Yes (npm + binary) |
| wkhtmltopdf binary | ~80 MB | No | Yes (outdated engine) |
| Gotenberg sidecar | +whole container | Separate compose service | Yes (version, config) |
| PDFPipe API | 0 bytes | Yes | No |
Before: Dockerfile with Chromium
# 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
# 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 (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)
# 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 (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
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 (not committed to git)
PDFPIPE_API_KEY=pp_live_your_keyCommon 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.