<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Michael Rice</title>
    <description>The latest articles on DEV Community by Michael Rice (@michaelrice_dev).</description>
    <link>https://dev.to/michaelrice_dev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4143040%2F00c7e63b-85af-4d9d-a448-03ba53ffe8ae.jpg</url>
      <title>DEV Community: Michael Rice</title>
      <link>https://dev.to/michaelrice_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/michaelrice_dev"/>
    <language>en</language>
    <item>
      <title>Generating branded invoice PDFs from a Cloudflare Worker (without keeping the files)</title>
      <dc:creator>Michael Rice</dc:creator>
      <pubDate>Fri, 25 Sep 2026 13:51:00 +0000</pubDate>
      <link>https://dev.to/michaelrice_dev/generating-branded-invoice-pdfs-from-a-cloudflare-worker-without-keeping-the-files-3ia0</link>
      <guid>https://dev.to/michaelrice_dev/generating-branded-invoice-pdfs-from-a-cloudflare-worker-without-keeping-the-files-3ia0</guid>
      <description>&lt;p&gt;Every SaaS I've built hits the same wall eventually: &lt;em&gt;"can we get a PDF invoice?"&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;Here's the architecture I've settled on for branded invoices, receipts, reports and certificates, plus the trade-offs I hit along the way.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Keep the edge dumb, keep Chromium somewhere else
&lt;/h3&gt;

&lt;p&gt;Cloudflare Workers are great for auth, validation and templating, but they can't run a full Chromium. So I split it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client ──► Worker (Hono): API key check · validate JSON · fill template · count usage (D1)
               │
               └──► Gotenberg (Chromium in a container): HTML ──► PDF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://gotenberg.dev" rel="noopener noreferrer"&gt;Gotenberg&lt;/a&gt; is an open-source Docker API around Chromium and LibreOffice. The Worker posts the rendered HTML as &lt;code&gt;index.html&lt;/code&gt; to &lt;code&gt;/forms/chromium/convert/html&lt;/code&gt; and streams the PDF straight back.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Templates as plain HTML with brand tokens
&lt;/h3&gt;

&lt;p&gt;Each template is a normal HTML file with &lt;code&gt;{{tokens}}&lt;/code&gt; (simplified):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"top"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"border-bottom:4px solid {{brand_color}}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"{{logo_url}}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;INVOICE&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt; #{{invoice_number}}
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lessons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Escape everything.&lt;/strong&gt; Customer names end up in invoices, and so does &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escaping HTML isn't enough for tokens that land in CSS.&lt;/strong&gt; &lt;code&gt;brand_color&lt;/code&gt; has to be a hex color, and &lt;code&gt;font&lt;/code&gt; is restricted to letters, digits, spaces and hyphens. Otherwise &lt;code&gt;red;}&amp;lt;/style&amp;gt;&amp;lt;script&amp;gt;&lt;/code&gt; is a fun afternoon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let CSS own the paper size.&lt;/strong&gt; Each template sets &lt;code&gt;@page { size: A4 portrait }&lt;/code&gt; (or &lt;code&gt;Letter&lt;/code&gt;, or &lt;code&gt;landscape&lt;/code&gt; for certificates), and the Worker sends &lt;code&gt;preferCssPageSize=true&lt;/code&gt; to Gotenberg so Chromium respects it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Validate before you spend a render
&lt;/h3&gt;

&lt;p&gt;The API accepts exactly one of &lt;code&gt;{html}&lt;/code&gt; or &lt;code&gt;{template_id, data}&lt;/code&gt; and returns a 422 with a list of every problem, rather than failing one at a time. There's also a &lt;code&gt;?preview=html&lt;/code&gt; mode that returns the filled HTML without rendering, which is great for iterating on templates in a browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Don't let Chromium fetch your internal network
&lt;/h3&gt;

&lt;p&gt;If you accept raw HTML, you've built a browser that fetches whatever URL a stranger puts in it, including &lt;code&gt;http://169.254.169.254/&lt;/code&gt; (the cloud metadata server) or &lt;code&gt;localhost&lt;/code&gt;. I block it in two places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;In the Worker:&lt;/strong&gt; a static scan designed to catch HTML (and &lt;code&gt;logo_url&lt;/code&gt;) that references private, loopback, link-local or metadata hosts, including sneaky forms like &lt;code&gt;http://2852039166/&lt;/code&gt;, &lt;code&gt;http:\\169.254.169.254&lt;/code&gt; or &lt;code&gt;127.0.0.1.nip.io&lt;/code&gt;, 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In Gotenberg:&lt;/strong&gt; the renderer blocks private IPs at fetch time. &lt;code&gt;CHROMIUM_DENY_PRIVATE_IPS=true&lt;/code&gt; checks the address a hostname actually resolves to, and a &lt;code&gt;CHROMIUM_DENY_LIST&lt;/code&gt; regex covers the same ranges and hostnames.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Don't keep the documents
&lt;/h3&gt;

&lt;p&gt;Invoices are full of personal data. The simplest compliance story is not having the data at all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Documents are processed transiently and not retained. The PDF streams back with &lt;code&gt;Cache-Control: no-store&lt;/code&gt;, 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.&lt;/li&gt;
&lt;li&gt;Request bodies and PDFs are never logged. API keys are stored only as SHA-256 hashes.&lt;/li&gt;
&lt;li&gt;For async jobs, the plan is object storage with a 24 h lifecycle rule and deletion on first download.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  6. Counting usage on the free tier
&lt;/h3&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;usage_monthly&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- no row back = over the cap = 429&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  7. Hosting the Chromium part cheaply
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's next
&lt;/h3&gt;

&lt;p&gt;I'm turning this into a small hosted API called &lt;strong&gt;Slipmint&lt;/strong&gt; 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 &lt;a href="https://slipmint-api.mike-tusa.workers.dev" rel="noopener noreferrer"&gt;https://slipmint-api.mike-tusa.workers.dev&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It's not open yet. If you'd use this, tell me which template you'd want first.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>pdf</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
