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.
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:
-
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.
-
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.
-
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_commitcallback 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.
Related Articles
Rails ActiveSupport::Notifications: Custom Instrumentation for Metrics and Production Monitoring
Rails ActiveSupport::Notifications powers custom instrumentation, metrics, and APM integration. Subscribe to events, ...
Rails Error Reporter: Structured Error Handling Without rescue Spaghetti
Rails Error Reporter centralizes production error handling with handle, record, and report. Stop scattering rescue bl...
Rails StackProf: Flamegraph CPU Profiling for Ruby Production Applications
Rails StackProf flamegraph profiling reveals exactly where your Ruby burns CPU — sampling middleware, three profiling...