Every SaaS I've built hits the same wall eventually: "can we get a PDF invoice?" The usual answer is "spin up Puppeteer", and then you're babysitting a heavyweight Chromium container for a feature your users look at once a month.
Here's the architecture I've settled on for branded invoices, receipts, reports and certificates, plus the trade-offs I hit along the way.
A quick status note up front: this is a side project in development. There are no signups or API keys yet. What's below is how it's built so far.
1. Keep the edge dumb, keep Chromium somewhere else
Cloudflare Workers are great for auth, validation and templating, but they can't run a full Chromium. So I split it:
client ──► Worker (Hono): API key check · validate JSON · fill template · count usage (D1)
│
└──► Gotenberg (Chromium in a container): HTML ──► PDF
Gotenberg is an open-source Docker API around Chromium and LibreOffice. The Worker posts the rendered HTML as index.html to /forms/chromium/convert/html and streams the PDF straight back.
2. Templates as plain HTML with brand tokens
Each template is a normal HTML file with {{tokens}} (simplified):
<div class="top" style="border-bottom:4px solid {{brand_color}}">
<img src="{{logo_url}}"> <h1>INVOICE</h1> #{{invoice_number}}
</div>
Three lessons:
-
Escape everything. Customer names end up in invoices, and so does
<script>. -
Escaping HTML isn't enough for tokens that land in CSS.
brand_colorhas to be a hex color, andfontis restricted to letters, digits, spaces and hyphens. Otherwisered;}</style><script>is a fun afternoon. -
Let CSS own the paper size. Each template sets
@page { size: A4 portrait }(orLetter, orlandscapefor certificates), and the Worker sendspreferCssPageSize=trueto Gotenberg so Chromium respects it.
3. Validate before you spend a render
The API accepts exactly one of {html} or {template_id, data} and returns a 422 with a list of every problem, rather than failing one at a time. There's also a ?preview=html mode that returns the filled HTML without rendering, which is great for iterating on templates in a browser.
4. Don't let Chromium fetch your internal network
If you accept raw HTML, you've built a browser that fetches whatever URL a stranger puts in it, including http://169.254.169.254/ (the cloud metadata server) or localhost. I block it in two places:
-
In the Worker: a static scan designed to catch HTML (and
logo_url) that references private, loopback, link-local or metadata hosts, including sneaky forms likehttp://2852039166/,http:\\169.254.169.254or127.0.0.1.nip.io, and reject it with a 422. A text scan can't see everything (DNS names that resolve internally, URLs built in JavaScript), so it isn't the last line. -
In Gotenberg: the renderer blocks private IPs at fetch time.
CHROMIUM_DENY_PRIVATE_IPS=truechecks the address a hostname actually resolves to, and aCHROMIUM_DENY_LISTregex covers the same ranges and hostnames.
5. Don't keep the documents
Invoices are full of personal data. The simplest compliance story is not having the data at all:
- Documents are processed transiently and not retained. The PDF streams back with
Cache-Control: no-store, and it isn't saved: the renderer's temporary files live in Cloud Run's in-memory filesystem and are cleaned up after each conversion. - Request bodies and PDFs are never logged. API keys are stored only as SHA-256 hashes.
- For async jobs, the plan is object storage with a 24 h lifecycle rule and deletion on first download.
6. Counting usage on the free tier
My first version used Workers KV for the monthly counter: one write per render. On the free plan KV allows 1,000 writes a day and one write per second to the same key, and read-modify-write isn't atomic. So I moved the counter to D1 (SQLite), which allows 100,000 rows written per day on the free tier. A single conditional upsert reserves the document before rendering and refunds it if the render fails:
INSERT INTO usage_monthly (key_id, month, docs) VALUES (?1, ?2, 1)
ON CONFLICT (key_id, month) DO UPDATE SET docs = docs + 1 WHERE docs < ?3
RETURNING docs; -- no row back = over the cap = 429
7. Hosting the Chromium part cheaply
I compared Cloud Run (scale to zero, generous free tier, but cold starts), a small always-on VPS (no cold starts, a few euros a month), and Cloudflare's own managed browser product (no second vendor, but lock-in). I went with Cloud Run. My config is 1 vCPU and 1 GiB of memory, scale to zero, at most one instance, and a $1 budget wired to a small function that disables billing on the project if spend ever crosses it. That budget covers the whole billing account, not just this project, and budget alerts can arrive hours late, so it's a backstop rather than an instant cutoff. The trade-off is cold starts on the first request after idle. I'll write up measured numbers once I have more than a handful of runs.
What's next
I'm turning this into a small hosted API called Slipmint with four templates (invoice, receipt, report, certificate), aimed at indie SaaS and agencies who need client-branded documents. You can preview the templates and docs at https://slipmint-api.mike-tusa.workers.dev.
It's not open yet. If you'd use this, tell me which template you'd want first.
Top comments (0)