Skip to content
CastPDF

Rails PDF generation from your existing views

For Rails PDF generation without a binary on every server, render an ERB view with render_to_string, post the HTML (or JSON for a saved template) to CastPDF with Net::HTTP, and hand the bytes to send_data. Background documents run in Active Job. It is the same PDF generation API any stack can call, with print CSS that paginates properly.

  • Updated October 2026
  • 100 free PDFs a month

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.

Common ways to create PDFs in Rails
Gem or serviceHow it worksKeep in mind
PrawnA Ruby DSL that places text, tables and images on the pagePure Ruby with fine control; layouts live in code rather than in ERB and CSS
Wicked PDFRenders your ERB view, then calls the wkhtmltopdf binaryFamiliar Rails helpers; the binary must be installed everywhere and the wkhtmltopdf project was archived in 2023
GroverCalls Puppeteer from Ruby to print with headless ChromeModern CSS; you install and maintain Node, Puppeteer and Chrome next to Ruby
CastPDFYour HTML or a saved template, printed by Chromium with a print engine on our sideOnly 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.

bin/rails credentials:edit
castpdf:
  api_key: paste_your_cpdf_test_key_here
  packing_list_template_id: paste_the_template_uuid_here

A 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.

app/services/castpdf_client.rb
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
end

Return 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.

app/controllers/packing_lists_controller.rb
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
end

Route 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.

app/jobs/packing_list_pdf_job.rb
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
end

Enqueue 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:

Packing list data sent to the template
{
  "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.dig returns nil and requests fail with 401.
  • Keep read_timeout above 60 seconds. A render can take up to 30 seconds, and a busy minute adds queue time on top.
  • Check Puma and any Rack::Timeout settings. 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 includes when 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

Rails specific problems and their fixes
SymptomLikely causeFix
ActionView::MissingTemplate in render_to_stringThe request format is pdf, so Rails looks for show.pdf.erbPass formats: [:html] as in the controller above
The PDF has no styles or imagesAsset helpers produced relative paths that the renderer cannot reachInline the CSS and use full public https URLs or data: URLs for images
Net::ReadTimeoutThe read timeout is lower than the render timeRaise read_timeout and retry with the same idempotency key
invalid_api_key (401) on workers onlyThe master key is set on web servers but not on job workersProvide RAILS_MASTER_KEY to every process
template_render_error (422)ERB tags such as <%= %> were pasted into a saved Liquid templateUse Liquid tags in saved templates, ERB only in views you render yourself
rate_limited (429) while testing a batchTest keys allow 20 requests per minuteLet 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.

FAQ

Common questions

Can I move from Wicked PDF without rewriting my views?

Usually yes. Keep rendering the same ERB view with render_to_string, then send the HTML to the API instead of to wkhtmltopdf. Expect to remove a few wkhtmltopdf specific options and helpers.

Which HTTP library should a Rails app use to call CastPDF?

Net::HTTP from the standard library is enough, so no extra gem is required. Faraday or HTTParty work just as well if your app already uses them. The client class on this page is about 40 lines.

Does this work on Heroku and other platforms without system packages?

Yes. Your dynos or containers only make an outgoing HTTPS request, so no buildpack for wkhtmltopdf or Chrome is needed. Long documents should still run in a worker process.

How do I attach the generated PDF with Active Storage?

Wrap the returned bytes in a StringIO and call attach with a filename and the application/pdf content type. The job example on this page shows the exact call.

Should I still use Prawn for anything?

Prawn is a good choice for documents you prefer to build in Ruby code, such as small labels with a fixed layout. Use HTML and the API when designers need to change the look, or when tables run over many pages.

Can a Rails mailer attach the PDF?

Yes. Your mailer can add the bytes with attachments["file.pdf"] in the normal way. CastPDF only creates the document; sending the message stays in your app.

Make your first PDF in 5 minutes

Pick a template, add your details and download your PDF. You get 100 free PDFs every month, and you don’t need a card.