Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save calston/1a561f7ea5a729e062f30e51cbc8ce6a to your computer and use it in GitHub Desktop.

Select an option

Save calston/1a561f7ea5a729e062f30e51cbc8ce6a to your computer and use it in GitHub Desktop.

Ruby PDF-to-S3 Microservice

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


File layout

.
├─ app.rb
├─ config.ru
├─ Gemfile
├─ Dockerfile
├─ README.md
├─ lib/
│  └─ template_registry.rb
└─ templates/
   ├─ invoice.rb
   └─ letter.rb

Gemfile

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

# config.ru
require_relative "./app"
run App

app.rb

# 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

# 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

# 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

# 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

# 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"]

README.md

# 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.0

Generate a PDF

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

Add your own templates

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
end

Then register:

REGISTRY = {
  "invoice" => Templates::Invoice,
  "letter"  => Templates::Letter,
  "my_template" => Templates::MyTemplate
}.freeze

Docker

docker 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

Notes

  • 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 in invoice.rb.
  • For pre-signed URLs, you can add an endpoint that calls Aws::S3::Presigner.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment