RUBY ON RAILS · 24 MIN READ ·

Rails PDF Generation: Prawn, Grover, and HTML-to-PDF in Production

Rails PDF generation with Prawn, Grover, and HTML-to-PDF via headless Chrome — when to use each, production setup, background jobs, and S3 storage.

Rails PDF Generation: Prawn, Grover, and HTML-to-PDF in Production

The blank PDF showed up in a Sentry report at 2 AM, three days before the client’s annual filing deadline.

No exception. No stack trace. The job had completed successfully, the file had been stored in S3, and the download link worked perfectly. The PDF just had no text. Two hundred pages of carefully formatted financial data, completely blank, as if the generator had run out of ink.

After nineteen years of Rails, I have encountered this exact failure mode four times across different projects. Every time, the cause is the same: a Prawn-generated PDF that works in development falls apart in production because the font files are not where the code expects them to be. No error — Prawn silently skips text it cannot render with the requested font and moves on.

That morning’s fix was a single Dockerfile line. But the deeper lesson was about understanding which PDF generation approach is right for which job, and what each one needs to run reliably in a container.

The Three Approaches to Rails PDF Generation

Rails developers in 2026 reach for one of three strategies:

  1. Prawn — pure-Ruby programmatic PDF construction. You place every element explicitly, in a coordinate system. Best for receipts, invoices, certificates: documents with predictable, structured content where you want pixel-precise control.

  2. Grover — HTML-to-PDF via headless Chromium. You render a normal HTML template (ERB, Phlex, whatever you use), then Chrome prints it. Best for complex layouts, charts, branded reports, anything you already build as a web page.

  3. PDFKit / WickedPDF — the older HTML-to-PDF approach via wkhtmltopdf. Both wkhtmltopdf and WickedPDF are essentially unmaintained at this point. If you have them in a project, replace them with Grover. I will not cover these in depth.

The decision is usually straightforward: if you are generating structured data documents (invoices, packing slips, compliance reports), use Prawn. If you are printing web pages or marketing documents where the HTML already exists or needs sophisticated styling, use Grover.

Prawn for Invoices and Structured Documents

Add the gem:

# Gemfile
gem "prawn"
gem "prawn-table"

A minimal invoice generator:

# app/services/pdf/invoice_generator.rb

class Pdf::InvoiceGenerator
  FONT_PATH = Rails.root.join("app/assets/fonts")
  LOGO_PATH = Rails.root.join("app/assets/images/logo.png")

  def initialize(invoice)
    @invoice = invoice
  end

  def generate
    Prawn::Document.new(page_size: "A4", margin: [40, 50, 40, 50]) do |pdf|
      configure_fonts(pdf)
      render_header(pdf)
      render_line_items(pdf)
      render_totals(pdf)
      render_footer(pdf)
    end
  end

  private

  def configure_fonts(pdf)
    pdf.font_families.update(
      "Inter" => {
        normal:      FONT_PATH.join("Inter-Regular.ttf"),
        bold:        FONT_PATH.join("Inter-Bold.ttf"),
        italic:      FONT_PATH.join("Inter-Italic.ttf"),
        bold_italic: FONT_PATH.join("Inter-BoldItalic.ttf")
      }
    )
    pdf.font "Inter"
  end

  def render_header(pdf)
    pdf.image LOGO_PATH.to_s, width: 120
    pdf.move_down 20

    pdf.text "Invoice ##{@invoice.number}", size: 22, style: :bold
    pdf.text @invoice.created_at.strftime("%B %-d, %Y"), size: 10, color: "666666"
    pdf.move_down 30

    pdf.font_size(10) do
      pdf.text_box @invoice.customer.billing_address,
        at:    [pdf.bounds.right - 200, pdf.cursor],
        width: 200
    end
  end

  def render_line_items(pdf)
    pdf.move_down 40
    headers = ["Description", "Qty", "Unit Price", "Total"]
    rows    = @invoice.line_items.map do |item|
      [item.description, item.quantity, format_money(item.unit_price_cents), format_money(item.total_cents)]
    end

    Prawn::Table.new([headers] + rows, pdf,
      header:         true,
      row_colors:     %w[F9F9F9 FFFFFF],
      cell_style:     { size: 10, padding: [8, 10] },
      column_widths:  [280, 50, 90, 90]
    ) do |t|
      t.row(0).font_style = :bold
      t.row(0).background_color = "1A1A2E"
      t.row(0).text_color = "FFFFFF"
      t.columns(1..3).align = :right
    end.draw
  end

  def render_totals(pdf)
    pdf.move_down 20
    subtotal = @invoice.subtotal_cents
    tax      = @invoice.tax_cents
    total    = @invoice.total_cents

    [[nil, "Subtotal", format_money(subtotal)],
     [nil, "VAT (21%)", format_money(tax)],
     [nil, "Total", format_money(total)]].each_with_index do |(_, label, amount), i|
      style = i == 2 ? :bold : :normal
      pdf.font_size(10) do
        pdf.text_box label,  at: [340, pdf.cursor], width: 100, style: style
        pdf.text_box amount, at: [440, pdf.cursor], width: 75, align: :right, style: style
      end
      pdf.move_down i == 2 ? 20 : 14
    end
  end

  def render_footer(pdf)
    pdf.bounding_box([0, 30], width: pdf.bounds.width) do
      pdf.stroke_color "DDDDDD"
      pdf.stroke_horizontal_rule
      pdf.move_down 8
      pdf.font_size(8) { pdf.text @invoice.company.footer_text, color: "888888" }
    end
  end

  def format_money(cents)
    "€ #{format('%.2f', cents.to_f / 100)}"
  end
end

Call it from a controller or job:

# app/controllers/invoices_controller.rb

def download
  invoice = current_account.invoices.find(params[:id])
  pdf     = Pdf::InvoiceGenerator.new(invoice).generate

  send_data pdf.render,
    filename:    "invoice-#{invoice.number}.pdf",
    type:        "application/pdf",
    disposition: "inline"
end

The Font Problem

Prawn requires TTF files at the paths you specify. In development, Rails.root.join("app/assets/fonts") points at your local checkout. In a Docker container, it points at wherever the container’s working directory lands — which may or may not contain the fonts, depending on what you COPY in your Dockerfile.

The fix is explicit and boring. In your Dockerfile:

# Dockerfile
COPY app/assets/fonts /rails/app/assets/fonts

If you use Propshaft or Sprockets asset compilation and fonts end up in public/assets, update the path accordingly. The key is: test File.exist? on each font path in a startup check, not in the PDF generator itself.

# config/initializers/pdf_fonts.rb
Pdf::InvoiceGenerator::FONTS.values.flatten.each do |path|
  raise "PDF font missing: #{path}" unless File.exist?(path)
end

This converts a silent runtime failure into a loud startup failure that surfaces in CI, not at 2 AM.

Grover for HTML-to-PDF Conversion

Grover uses Puppeteer under the hood, which controls a Chromium instance. The gem calls a Node.js script that launches Chrome, loads your page, and calls Chrome’s print-to-PDF API. The result is a PDF that matches what a user sees in the browser.

# Gemfile
gem "grover"

You also need Node.js and Puppeteer in the environment:

npm install puppeteer

The simplest use: render an ERB template to HTML, hand it to Grover:

# app/services/pdf/report_generator.rb

class Pdf::ReportGenerator
  def initialize(report)
    @report = report
  end

  def generate
    html = ApplicationController.render(
      template: "reports/pdf",
      layout:   "pdf",
      assigns:  { report: @report }
    )

    Grover.new(html, format: "A4", print_background: true).to_pdf
  end
end

The PDF layout (app/views/layouts/pdf.html.erb) is a minimal HTML page. No turbo, no stimulus, no JavaScript that depends on a live server:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    * { box-sizing: border-box; }
    body { font-family: 'Helvetica Neue', Arial, sans-serif; font-size: 11px; color: #111; }
    .page-break { page-break-after: always; }
    @page { margin: 15mm 20mm; }
  </style>
</head>
<body>
  <%= yield %>
</body>
</html>

Running Chromium in Docker

The hardest part of Grover in production is Chromium. Chrome requires several system libraries that are not in a minimal Ruby image, and it needs to run in no-sandbox mode in containers without user namespaces.

# Dockerfile (excerpt)
RUN apt-get update && apt-get install -y --no-install-recommends \
      chromium \
      chromium-driver \
      fonts-liberation \
      libnss3 \
      libatk-bridge2.0-0 \
      libx11-xcb1 \
      libxcomposite1 \
      libxdamage1 \
      libxrandr2 \
      libgbm1 \
      libasound2 \
    && rm -rf /var/lib/apt/lists/*

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

Then configure Grover to use the system Chromium and the required flags:

# config/initializers/grover.rb

Grover.configure do |config|
  config.options = {
    executable_path:   ENV.fetch("PUPPETEER_EXECUTABLE_PATH", nil),
    args:              %w[
      --no-sandbox
      --disable-setuid-sandbox
      --disable-dev-shm-usage
      --disable-gpu
      --no-first-run
      --no-zygote
    ],
    timeout:           30_000,
    wait_until:        "networkidle0",
    emulate_media_type: "print"
  }
end

--no-sandbox is a hard requirement in Docker containers without user namespace remapping. --disable-dev-shm-usage prevents Chrome from crashing when /dev/shm (shared memory) is too small — the default 64MB in Docker is not enough for Chrome’s renderer processes.

If you use Kamal, add the Chromium packages to a custom Dockerfile rather than the base image, and make sure your Kamal role configuration has enough memory — a Grover render takes 150–300 MB per Chrome instance.

Grover with Inline CSS vs. External Stylesheets

Chrome in Grover runs without network access to your Rails server unless you configure a base URL. Relative <link> tags to your CSS files will not resolve. Two clean options:

Option 1: Inline the CSS. Generate the CSS as a string in the layout:

<style>
  <%= File.read(Rails.root.join("public/assets/css/pdf.css")) %>
</style>

Option 2: Set a base URL. Configure Grover to use an internal base URL so it can fetch assets from your app:

Grover.new(html,
  format:   "A4",
  base_url: "http://localhost:3000"
).to_pdf

This only works if your Rails server is accessible from the PDF generation process — fine for Sidekiq jobs on the same host, fragile in a distributed environment. Inline CSS is simpler and more portable.

Background Jobs for Rails PDF Generation

Generating PDFs synchronously in a controller action blocks a Puma thread for the duration. A complex Grover render can take 3–8 seconds. That is unacceptable in a web request.

The pattern I use: generate in a background job, store the result, and poll or push-notify the user when it is ready.

# app/jobs/invoice_pdf_job.rb

class InvoicePdfJob < ApplicationJob
  queue_as :pdf_generation

  def perform(invoice_id)
    invoice = Invoice.find(invoice_id)

    pdf_bytes = Pdf::InvoiceGenerator.new(invoice).generate.render

    invoice.pdf.attach(
      io:           StringIO.new(pdf_bytes),
      filename:     "invoice-#{invoice.number}.pdf",
      content_type: "application/pdf"
    )

    invoice.update!(pdf_status: "ready")
    InvoiceReadyMailer.notify(invoice).deliver_later
  rescue => e
    invoice&.update!(pdf_status: "failed")
    raise
  end
end

The pdf attachment uses Active Storage, which you should already have configured with S3 for production. Store the status on the model so the UI can show a spinner until pdf_status == "ready":

# app/models/invoice.rb
has_one_attached :pdf
enum :pdf_status, { pending: 0, ready: 1, failed: 2 }, default: :pending

Enqueue the job when the invoice is finalized:

class Invoice < ApplicationRecord
  after_update_commit :enqueue_pdf_generation, if: :saved_change_to_finalized_at?

  private

  def enqueue_pdf_generation
    InvoicePdfJob.perform_later(id)
  end
end

For the download endpoint, check status and either serve the stored PDF or redirect to a generation-in-progress page:

def download
  invoice = current_account.invoices.find(params[:id])

  case invoice.pdf_status
  when "ready"
    redirect_to rails_blob_url(invoice.pdf, disposition: "inline")
  when "pending"
    render json: { status: "pending" }, status: :accepted
  when "failed"
    render json: { status: "failed" }, status: :unprocessable_entity
  end
end

A Turbo Stream polling at the front end handles the pending state cleanly without websockets.

Dedicated Queue and Worker Configuration

PDF generation is compute-intensive and should not compete with your regular application jobs. Give it a dedicated queue and limit concurrency so you do not spin up twelve Chrome instances simultaneously and exhaust memory:

# config/initializers/solid_queue.rb (or Sidekiq initializer)

# Solid Queue: in config/queue.yml
# pdf_generation:
#   workers: 2
#   threads: 1   # one PDF at a time per worker

# Sidekiq: in config/sidekiq.yml
# :queues:
#   - [pdf_generation, 2]
# :concurrency: 1  # for the PDF worker process

If you run Sidekiq, start a separate process for the PDF queue with concurrency 1:

bundle exec sidekiq -q pdf_generation -c 1

Each Chrome instance needs ~200 MB of RAM. Two workers at concurrency 1 means two simultaneous Chrome processes: 400 MB. Keep that budget in mind when sizing your server or configuring Kamal roles.

Caching: Pre-generating PDFs at the Right Moment

Generating a PDF on first access and caching it is not always the right call. Consider when the content can change:

  • Invoices: finalized invoices never change. Generate once, store forever. The after_update_commit callback approach above is correct.
  • Reports with dynamic date ranges: cannot cache; always generate fresh. Keep generation fast (aim for sub-2s with Prawn, sub-5s with Grover) and use the background job pattern.
  • Monthly statements: generate at month-close, store. Send a “your statement is ready” email rather than generating on demand.

For PDFs that need to be regenerated when data changes, tie generation to a version key:

def current_pdf_version
  Digest::SHA1.hexdigest([
    @report.updated_at.to_i.to_s,
    @report.line_items.maximum(:updated_at).to_i.to_s
  ].join)
end

Store the version alongside the attachment and re-enqueue if the version changes. This avoids the stale cache problem without invalidating everything on every write.

Testing PDF Generation

Testing PDFs requires a different approach than testing HTML output. You cannot easily assert the visual layout, but you can assert the structure and content.

For Prawn, test that the generated bytes are a valid PDF and contain expected text:

# test/services/pdf/invoice_generator_test.rb

class InvoiceGeneratorTest < ActiveSupport::TestCase
  setup do
    @invoice = invoices(:confirmed)
  end

  test "generates a valid PDF" do
    pdf = Pdf::InvoiceGenerator.new(@invoice).generate.render
    assert pdf.start_with?("%PDF"), "output should be a PDF"
    assert pdf.bytesize > 1_000, "PDF should not be empty"
  end

  test "includes invoice number in rendered output" do
    pdf_text = Prawn::Document.new do |doc|
      doc.font_families.update("Helvetica" => { normal: "Helvetica" })
    end.render

    # For content assertions, render and re-parse with pdf-reader
    require "pdf/reader"
    reader = PDF::Reader.new(StringIO.new(Pdf::InvoiceGenerator.new(@invoice).generate.render))
    full_text = reader.pages.map(&:text).join(" ")

    assert_includes full_text, @invoice.number
    assert_includes full_text, @invoice.customer.name
  end
end

The pdf-reader gem lets you extract text from a rendered PDF, which covers the most important assertion: does the content appear in the document? Add it to the test group only:

gem "pdf-reader", group: :test

For Grover in tests, avoid spinning up Chrome on every test run. Stub the generator at the service layer:

RSpec.describe InvoicePdfJob do
  it "attaches a PDF to the invoice" do
    invoice = create(:invoice, :confirmed)
    allow(Pdf::InvoiceGenerator).to receive(:new).and_return(
      instance_double(Pdf::InvoiceGenerator, generate: double(render: "%PDF fake"))
    )

    InvoicePdfJob.perform_now(invoice.id)

    expect(invoice.reload.pdf).to be_attached
    expect(invoice.pdf_status).to eq("ready")
  end
end

Reserve actual Chrome rendering for a small set of integration tests that run separately from the unit suite — a pdf RSpec tag, excluded from the default run.

Production Gotchas Checklist

After running Rails PDF generation in production for years, here are the things that break:

Font files missing in containers. Covered above. Add a startup check and add an explicit COPY in the Dockerfile.

Grover timeouts. Increase timeout in the Grover config and set a generous wait_until. For pages with JavaScript-rendered content, networkidle0 waits for all network requests to settle — but it can wait forever if your page makes long-polling requests. Use networkidle2 (at most two inflight requests) instead.

Chrome OOM kills. Docker containers without an explicit memory limit can exhaust the host. Set a memory limit on the container running PDF generation jobs. Chrome’s renderer processes each consume 50–200 MB depending on page complexity.

Active Storage disk space. PDF archives grow. Set a lifecycle policy on your S3 bucket to transition old PDFs to Glacier after 90 days and delete them after 7 years (most compliance requirements). The Active Storage S3 configuration post covers S3 bucket policies.

Prawn rendering Unicode characters incorrectly. Prawn’s built-in font metrics do not cover non-Latin scripts. If you generate PDFs for customers with Japanese, Arabic, or CJK content, embed a font that covers those code points (Noto Sans is the standard choice) and handle the font selection per locale.

Wrong paper size. Prawn defaults to US Letter. European customers expect A4. Set page_size: "A4" explicitly and document it. I have fixed this bug twice on the same project because someone added a new generator without reading the existing one.

FAQ

Should I use Prawn or Grover for Rails PDF generation?

Use Prawn when you need precise programmatic control over layout: invoices, packing slips, certificates, compliance documents with strict formatting rules. Prawn is pure Ruby — no system dependencies, no headless browser, faster startup, and easier to reason about. Use Grover when your document is an HTML page you already build (reports, dashboards, marketing materials), or when you need CSS layout features that are painful to replicate in Prawn’s coordinate system. Grover’s output quality matches what users see in the browser, which is a significant advantage for complex layouts.

How do I add page numbers to Prawn PDFs?

Prawn has a number_pages method that processes the finished document and stamps page numbers onto each page:

pdf = Prawn::Document.new(page_size: "A4")
# ... render content across multiple pages ...
pdf.number_pages "<page> / <total>",
  at:     [pdf.bounds.right - 50, 0],
  align:  :right,
  size:   9,
  color:  "888888"
pdf.render

Call it after all content is rendered, not during. Prawn resolves <total> in a second pass over the finished document.

How do I handle multi-page content in Grover?

Chrome handles pagination automatically according to @page and page-break-after / break-before CSS rules. Set the page dimensions and margins in the PDF layout’s @page block:

@page {
  size: A4;
  margin: 15mm 20mm;
}

Use .page-break classes in your template to force breaks at section boundaries. Grover respects CSS column layout and flexbox — complex multi-column PDFs that would take 300 lines of Prawn code are straightforward HTML.

Is WickedPDF still a viable option in 2026?

No. wkhtmltopdf has not had a release since 2020 and is not maintained. The rendering engine is ancient WebKit that does not support modern CSS — Flexbox, Grid, and CSS custom properties are partially or completely broken. WickedPDF wraps wkhtmltopdf. If you are using WickedPDF today, migrate to Grover. The HTML templates transfer with minor adjustments; the main change is replacing the Grover initializer for Puppeteer flags and dropping the wkhtmltopdf binary from your servers.


PDF generation is one of those Rails features that looks simple until it fails silently at 2 AM. TTB Software builds and maintains production Rails applications — from invoice generators to compliance reporting pipelines. Nineteen years of Rails means we have already hit every edge case you are about to discover.

#rails-pdf-generation #prawn-ruby #grover-rails #html-to-pdf-rails #rails-invoice-pdf #headless-chrome-rails #rails-pdf-background-jobs

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software