PDF gems for Rails, side by side
Most Rails apps pick one of three gems. They take different paths to the same file: drawing the page in Ruby, converting HTML with an external program, or driving a real browser. What changes is how you design documents and what your Dockerfile or Heroku buildpack has to include.
| Gem or service | How it works | Keep in mind |
|---|---|---|
| Prawn | A Ruby DSL that places text, tables and images on the page | Pure Ruby with fine control; layouts live in code rather than in ERB and CSS |
| Wicked PDF | Renders your ERB view, then calls the wkhtmltopdf binary | Familiar Rails helpers; the binary must be installed everywhere and the wkhtmltopdf project was archived in 2023 |
| Grover | Calls Puppeteer from Ruby to print with headless Chrome | Modern CSS; you install and maintain Node, Puppeteer and Chrome next to Ruby |
| CastPDF | Your HTML or a saved template, printed by Chromium with a print engine on our side | Only Net::HTTP in the app; a request per document and a plan above the free allowance |
Prawn is a fine tool for receipts with a fixed shape and remains popular for good reason. Wicked PDF was the default for years because it let you reuse views, and many apps still run it. The trouble starts when a warehouse needs a 12 page packing list where every page repeats the column headings, or when the server image moves to a distribution where the old binary will not install. An API lets you keep the view based workflow while the rendering engine becomes someone else’s job.
Put the key in Rails credentials
Rails credentials keep secrets encrypted in the repository, with the master key supplied separately. Run bin/rails credentials:edit (add --environment production for per environment files) and add a castpdf section holding your test key, which starts with cpdf_test_, plus the template ID used later on this page.
castpdf:
api_key: paste_your_cpdf_test_key_here
packing_list_template_id: paste_the_template_uuid_hereA small client with Net::HTTP
You do not need a gem for one POST request. This plain Ruby class sends the JSON, returns the PDF bytes on success and raises a typed error otherwise. It separates Busy (conflicts, rate limits and a briefly busy service, all worth retrying) from every other error, which a retry cannot fix.
require "net/http"
require "json"
class CastpdfClient
ENDPOINT = URI("https://api.castpdf.com/v1/pdf")
class Error < StandardError
attr_reader :status, :code
def initialize(status, code, message)
super("#{status} #{code}: #{message}")
@status = status
@code = code
end
end
class Busy < Error; end
def self.render(payload, idempotency_key: nil)
request = Net::HTTP::Post.new(ENDPOINT)
request["Authorization"] = "Bearer #{Rails.application.credentials.dig(:castpdf, :api_key)}"
request["Content-Type"] = "application/json"
request["Idempotency-Key"] = idempotency_key if idempotency_key
request.body = payload.to_json
response = Net::HTTP.start(ENDPOINT.host, ENDPOINT.port, use_ssl: true, open_timeout: 10, read_timeout: 75) do |http|
http.request(request)
end
return response.body if response.is_a?(Net::HTTPSuccess)
error = begin
JSON.parse(response.body).fetch("error", {})
rescue JSON::ParserError
{}
end
status = response.code.to_i
klass = [409, 429, 503].include?(status) ? Busy : Error
raise klass.new(status, error["code"], error["message"])
end
endReturn the PDF with send_data
The controller renders an ordinary ERB view to a string, sends it to CastPDF in print mode and streams the result with send_data. Here the document is a packing list a warehouse prints for each outgoing shipment. Scoping the lookup through current_account keeps one customer from fetching another’s shipment, and the idempotency key changes whenever the shipment is edited, so a corrected list never comes back stale.
class PackingListsController < ApplicationController
def show
shipment = current_account.shipments.includes(line_items: :product).find(params[:shipment_id])
html = render_to_string(
template: "packing_lists/show",
layout: "pdf",
formats: [:html],
locals: { shipment: shipment }
)
pdf = CastpdfClient.render(
{ html: html, mode: "print", format: "A4", margins: "14mm", filename: "packing-list-#{shipment.reference}" },
idempotency_key: "packing-list-#{shipment.id}-#{shipment.updated_at.to_i}"
)
send_data pdf,
filename: "packing-list-#{shipment.reference}.pdf",
type: "application/pdf",
disposition: "inline"
rescue CastpdfClient::Error => e
Rails.logger.error("Packing list PDF failed: #{e.message}")
head :bad_gateway
end
endRoute it with get "shipments/:shipment_id/packing_list", to: "packing_lists#show", as: :shipment_packing_list. The pdf layout should hold its CSS in a <style> tag, because the renderer fetches only public https URLs and cannot see asset paths on your own host. Mark the item table with a proper <thead>: in print mode its header row repeats on every page and no row is cut in half, which is exactly what a picker holding page 3 needs. Use disposition: "attachment" if you want a download instead of a browser tab.
Saved templates and Active Job
When packing lists are printed in batches at the start of a shift, nobody waits on a browser tab. Render them in Active Job and attach each file with Active Storage. This version uses a saved template from the dashboard instead of an ERB view, so operations staff can adjust the wording and sample values in Simple mode while your code sends only JSON.
class PackingListPdfJob < ApplicationJob
queue_as :documents
retry_on CastpdfClient::Busy, Net::ReadTimeout, Net::OpenTimeout, wait: :polynomially_longer, attempts: 5
def perform(shipment)
pdf = CastpdfClient.render(
{
template_id: Rails.application.credentials.dig(:castpdf, :packing_list_template_id),
data: {
shipment: { reference: shipment.reference, ship_date: shipment.ship_date.iso8601, carrier: shipment.carrier },
recipient: { name: shipment.recipient_name, city: shipment.city, country: shipment.country_code },
items: shipment.line_items.map { |li| { sku: li.product.sku, name: li.product.name, bin: li.bin_location, quantity: li.quantity } }
},
filename: "packing-list-#{shipment.reference}"
},
idempotency_key: "packing-list-#{shipment.id}-#{shipment.updated_at.to_i}"
)
shipment.packing_list.attach(
io: StringIO.new(pdf),
filename: "packing-list-#{shipment.reference}.pdf",
content_type: "application/pdf"
)
end
endEnqueue one job per shipment with PackingListPdfJob.perform_later(shipment). Network timeouts and Busy responses retry with a growing wait, and because the idempotency key is stable, a retry after a timeout returns the document that may already have been made. Other errors, such as a template problem, raise straight away so you can fix the cause. The payload the job sends looks like this:
{
"template_id": "7e3a1b9c-5d2f-4e6a-8b7c-1d2e3f4a5b6c",
"data": {
"shipment": { "reference": "SHP-20931", "ship_date": "2026-10-05", "carrier": "DPD" },
"recipient": { "name": "Kestrel Bike Works", "city": "Leeds", "country": "GB" },
"items": [
{ "sku": "CH-11S", "name": "11 speed chain", "bin": "A-04-2", "quantity": 20 },
{ "sku": "BP-DSC", "name": "Disc brake pads", "bin": "B-12-1", "quantity": 36 },
{ "sku": "TB-700", "name": "Inner tube 700x28", "bin": "C-02-5", "quantity": 50 }
]
},
"filename": "packing-list-SHP-20931"
}Production checklist for Rails
- Make sure
RAILS_MASTER_KEY(or the per environment key file) is present on every server and worker. Without it,credentials.digreturns nil and requests fail with401. - Keep
read_timeoutabove 60 seconds. A render can take up to 30 seconds, and a busy minute adds queue time on top. - Check Puma and any
Rack::Timeoutsettings. If a request is killed before the PDF arrives, move that document type to Active Job and show a link when it is ready. - Run PDF jobs on their own queue, such as
documents, so a large batch cannot delay password reset mail or other urgent work in Solid Queue or Sidekiq. - Use
includeswhen loading line items. A packing list with 80 rows should load in two queries, not 81. - Stub the client in tests with WebMock or a fake class that returns a fixture PDF, so your suite runs without network access or credentials.
- Keep each document within 50 pages and 40 MB, and switch from the test key to a
cpdf_live_key in production credentials when you launch.
Troubleshooting Rails PDF rendering
| Symptom | Likely cause | Fix |
|---|---|---|
ActionView::MissingTemplate in render_to_string | The request format is pdf, so Rails looks for show.pdf.erb | Pass formats: [:html] as in the controller above |
| The PDF has no styles or images | Asset helpers produced relative paths that the renderer cannot reach | Inline the CSS and use full public https URLs or data: URLs for images |
Net::ReadTimeout | The read timeout is lower than the render time | Raise read_timeout and retry with the same idempotency key |
invalid_api_key (401) on workers only | The master key is set on web servers but not on job workers | Provide RAILS_MASTER_KEY to every process |
template_render_error (422) | ERB tags such as <%= %> were pasted into a saved Liquid template | Use Liquid tags in saved templates, ERB only in views you render yourself |
rate_limited (429) while testing a batch | Test keys allow 20 requests per minute | Let retry_on wait, or run smaller batches during development |
The quickstart covers keys and starter templates, and the API reference lists every request field. For Ruby outside Rails, including scripts and Sinatra, see the Ruby page. The Django page shows the same view and task patterns in Python.