Pure-Ruby service to generate templated PDFs (no headless browser, no X server), upload them to Amazon S3, and return the S3 path.
- PDF engine: Prawn (+
prawn-table) — both pure Ruby - HTTP server: Sinatra
- Storage: AWS S3 via
aws-sdk-s3
✅ Meets constraint: “only native PDF libraries and not require X or a browser installed.”
.
├─ app.rb
├─ config.ru
├─ Gemfile
├─ Dockerfile
├─ README.md
├─ lib/
│ └─ template_registry.rb
└─ templates/
├─ invoice.rb
└─ letter.rb
source "https://rubygems.org"
gem "sinatra", "~> 3.0"
# Pure Ruby PDF generator
gem "prawn", "~> 2.5"
# Pure Ruby Prawn add-on for tables
gem "prawn-table", "~> 0.2"
# AWS S3 client
gem "aws-sdk-s3", "~> 1.146"# config.ru
require_relative "./app"
run App# app.rb
# frozen_string_literal: true
require "sinatra/base"
require "json"
require "securerandom"
require "aws-sdk-s3"
require_relative "lib/template_registry"
class App < Sinatra::Base
set :bind, "0.0.0.0"
set :port, ENV.fetch("PORT", 3000)
set :show_exceptions, false
helpers do
def json(body)
content_type :json
body.is_a?(String) ? body : JSON.dump(body)
end
end
before do
# Optional bearer token auth via ENV["AUTH_TOKEN"]
token = ENV["AUTH_TOKEN"]
if token && request.env["HTTP_AUTHORIZATION"] != "Bearer #{token}"
halt 401, json(error: "unauthorized")
end
end
error do
e = env["sinatra.error"]
status 500
json(error: "internal_error", message: e.message)
end
get "/health" do
json ok: true
end
get "/templates" do
json templates: TemplateRegistry.list
end
# POST /generate
# {
# "template": "invoice" | "letter",
# "data": { ... },
# "s3_bucket": "my-bucket", # optional if ENV S3_BUCKET is set
# "s3_prefix": "pdfs/", # optional, defaults to "pdf/"
# "filename": "custom.pdf", # optional
# "acl": "public-read" | "private" # optional, default "private"
# }
post "/generate" do
payload = JSON.parse(request.body.read) rescue {}
template_name = payload["template"] or halt 400, json(error: "missing template")
data = payload["data"] || {}
bucket = payload["s3_bucket"] || ENV["S3_BUCKET"] or halt 400, json(error: "missing s3_bucket")
prefix = (payload["s3_prefix"] || "pdf/").to_s
filename = (payload["filename"] || "#{template_name}-#{SecureRandom.uuid}.pdf").to_s
acl = (payload["acl"] || "private").to_s
key = File.join(prefix, filename).gsub(%r{^/+}, "")
pdf_bytes = TemplateRegistry.render(template_name, data)
s3 = Aws::S3::Client.new(region: ENV["AWS_REGION"] || "us-east-1")
s3.put_object(
bucket: bucket,
key: key,
body: pdf_bytes,
content_type: "application/pdf",
acl: acl
)
region = s3.config.region
s3_uri = "s3://#{bucket}/#{key}"
url = acl == "public-read" ? "https://#{bucket}.s3.#{region}.amazonaws.com/#{key}" : s3_uri
status 201
json bucket: bucket, key: key, s3_uri: s3_uri, url: url
end
end# lib/template_registry.rb
# frozen_string_literal: true
require "prawn"
require "prawn/table"
require_relative "../templates/invoice"
require_relative "../templates/letter"
module TemplateRegistry
REGISTRY = {
"invoice" => Templates::Invoice,
"letter" => Templates::Letter
}.freeze
def self.list
REGISTRY.keys
end
def self.render(name, data)
tmpl = REGISTRY[name]
raise ArgumentError, "unknown template '#{name}'" unless tmpl
tmpl.render(data)
end
end# templates/invoice.rb
# frozen_string_literal: true
module Templates
class Invoice
def self.render(data)
company = data["company"] || {}
bill_to = data["bill_to"] || {}
items = Array(data["items"]) # [{description, qty, unit_price, total?}]
tax_rate = (data["tax_rate"] || 0).to_f # 0.2 => 20%
issued_on = (data["date"] || Time.now.strftime("%Y-%m-%d")).to_s
number = (data["invoice_number"] || "INV-0001").to_s
pdf = Prawn::Document.new(page_size: "A4", margin: 36)
# Optional custom font if you drop a TTF at ./fonts/DejaVuSans.ttf
if File.exist?("fonts/DejaVuSans.ttf")
pdf.font_families.update("DejaVuSans" => { normal: "fonts/DejaVuSans.ttf" })
pdf.font("DejaVuSans")
end
# Header
pdf.text company.fetch("name", ""), size: 20, style: :bold
pdf.move_down 4
pdf.text company.fetch("address", "")
pdf.text company.fetch("email", "")
pdf.move_up 40
pdf.bounding_box([350, pdf.cursor + 40], width: 200) do
pdf.text "INVOICE", size: 22, style: :bold, align: :right
pdf.text "No: #{number}", align: :right
pdf.text "Date: #{issued_on}", align: :right
end
pdf.move_down 20
pdf.text "Bill To:", style: :bold
[bill_to["name"], bill_to["address"], bill_to["email"]].compact.each { |t| pdf.text(t.to_s) }
pdf.move_down 20
# Items table
header = ["Description", "Qty", "Unit Price", "Line Total"]
table_data = [header]
subtotal = 0.0
items.each do |it|
qty = (it["qty"] || 0).to_f
price = (it["unit_price"] || 0).to_f
line = (it["total"] || (qty * price)).to_f
subtotal += line
table_data << [
it["description"].to_s,
qty,
format("%.2f", price),
format("%.2f", line)
]
end
pdf.table(table_data, header: true, width: pdf.bounds.width) do
row(0).font_style = :bold
columns(1..3).align = :right
self.row_colors = %w[F0F0F0 FFFFFF]
self.cell_style = { borders: [:top, :bottom], border_width: 0.5 }
end
tax = subtotal * tax_rate
total = subtotal + tax
pdf.move_down 10
pdf.bounding_box([0, pdf.cursor], width: pdf.bounds.width) do
pdf.text "Subtotal: #{format('%.2f', subtotal)}", align: :right
pdf.text "Tax (#{(tax_rate * 100).round(2)}%): #{format('%.2f', tax)}", align: :right
pdf.text "Total: #{format('%.2f', total)}", style: :bold, size: 14, align: :right
end
if data["notes"]
pdf.move_down 20
pdf.text "Notes", style: :bold
pdf.text data["notes"].to_s
end
pdf.number_pages "<page>/<total>", at: [pdf.bounds.right - 50, 0], align: :right, size: 8
pdf.render # return PDF bytes
end
end
end# templates/letter.rb
# frozen_string_literal: true
module Templates
class Letter
def self.render(data)
date = (data["date"] || Time.now.strftime("%Y-%m-%d")).to_s
from = data["from"] || {}
to = data["to"] || {}
subject = (data["subject"] || "Letter").to_s
body = (data["body"] || "").to_s
pdf = Prawn::Document.new(page_size: "A4", margin: 72)
pdf.text from.fetch("name", ""), style: :bold
pdf.text from.fetch("address", "")
pdf.move_down 20
pdf.text to.fetch("name", "")
pdf.text to.fetch("address", "")
pdf.move_down 20
pdf.text date
pdf.move_down 20
pdf.text "Re: #{subject}", style: :bold
pdf.move_down 10
body.split(/\n{2,}/).each do |para|
pdf.text para.strip, leading: 2
pdf.move_down 8
end
pdf.move_down 20
pdf.text "Kind regards,"
pdf.move_down 30
pdf.text from.fetch("name", ""), style: :bold
pdf.render
end
end
end# Dockerfile
FROM ruby:3.2-alpine
# Build tools for native gems if needed; prawn/aws-sdk-s3 are pure Ruby but bundler likes build-base present
RUN apk add --no-cache build-base
WORKDIR /app
COPY Gemfile Gemfile.lock* ./
RUN bundle install
COPY . .
ENV PORT=3000
EXPOSE 3000
CMD ["bundle", "exec", "rackup", "-p", "3000", "-o", "0.0.0.0", "config.ru"]# Ruby PDF-to-S3 Microservice
Pure Ruby PDF generation (Prawn) — **no browser, no X** — exposed via Sinatra, uploads to S3.
## Configure
Environment variables:
- `AWS_REGION` (e.g. `eu-west-2`)
- `S3_BUCKET` (default bucket if not passed in request)
- `AUTH_TOKEN` (optional; if set, clients must send `Authorization: Bearer <token>`)
- `PORT` (optional; default `3000`)
AWS creds via standard SDK sources (env, shared credentials, EC2/ECS role).
## Run locally
```bash
bundle install
AWS_REGION=eu-west-2 S3_BUCKET=my-bucket AUTH_TOKEN=secret \
bundle exec rackup -p 3000 -o 0.0.0.0curl -sS http://localhost:3000/generate \
-H "Authorization: Bearer secret" \
-H 'Content-Type: application/json' \
-d '{
"template": "invoice",
"data": {
"invoice_number": "INV-1001",
"date": "2025-08-14",
"company": {"name": "Tamvera Ltd", "address": "1 High St, MK", "email": "[email protected]"},
"bill_to": {"name": "Acme Corp", "address": "2 Main Rd", "email": "[email protected]"},
"items": [
{"description": "IoT Gateway", "qty": 2, "unit_price": 149.5},
{"description": "Soil sensor kit", "qty": 5, "unit_price": 39.0}
],
"tax_rate": 0.2,
"notes": "Thanks for your business"
},
"s3_bucket": "my-bucket",
"s3_prefix": "docs/invoices/",
"acl": "private"
}' | jq .Response:
{
"bucket": "my-bucket",
"key": "docs/invoices/invoice-<uuid>.pdf",
"s3_uri": "s3://my-bucket/docs/invoices/invoice-<uuid>.pdf",
"url": "s3://my-bucket/docs/invoices/invoice-<uuid>.pdf"
}If you set "acl": "public-read", url will be an HTTPS URL.
Create a new file in templates/, implement render(data) -> PDF bytes, and register it in lib/template_registry.rb.
module Templates
class MyTemplate
def self.render(data)
pdf = Prawn::Document.new
pdf.text "Hello, #{data["name"]}!"
pdf.render
end
end
endThen register:
REGISTRY = {
"invoice" => Templates::Invoice,
"letter" => Templates::Letter,
"my_template" => Templates::MyTemplate
}.freezedocker build -t pdfsvc:latest .
AWS_REGION=eu-west-2 S3_BUCKET=my-bucket AUTH_TOKEN=secret \
docker run --rm -p 3000:3000 -e AWS_REGION -e S3_BUCKET -e AUTH_TOKEN \
-e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_SESSION_TOKEN \
pdfsvc:latest- Pure Ruby; no headless Chromium/wkhtmltopdf/Ghostscript/etc.
- If you need non-Latin glyphs, mount a TTF font under
./fonts/and uncomment the font block ininvoice.rb. - For pre-signed URLs, you can add an endpoint that calls
Aws::S3::Presigner.