When someone shares a link on Facebook, LinkedIn, WhatsApp, or Slack, the platform fetches the page’s Open Graph meta tags and renders a preview card. If there’s no og:image, you get a blank box or a tiny favicon. That’s what your users are sharing on your behalf.

Most approaches to generating OG images involve either a dedicated image library (ImageMagick, Vips), a Node.js tool (Puppeteer, Satori), or a third-party SaaS (Cloudinary, imgix). I looked at all of these. But if you’re running a Rails app and you deliberately don’t have Node.js in your stack, there’s a simpler option: build the image as an HTML page, screenshot it with a headless browser, and store the PNG.

That’s what this post walks through. No Node.js. No third-party image service. Just Rails, Chromium, and Cloudflare.

The Approach

The idea is straightforward:

  1. Create a dedicated HTML page that looks exactly like the OG image you want
  2. Open it in a headless browser at 1200Ă—630px (the standard OG image size)
  3. Take a screenshot
  4. Attach the PNG to the record using Active Storage
  5. Serve it from a public CDN and reference it in the page’s meta tags

The point is that you’re designing the image with tools you already know: HTML, CSS, web fonts, ERB. Any front-end developer can modify the template. You get your app’s full design system for free.

The trade-off is that launching a headless browser is heavy. A few seconds per image. That’s fine for a background job triggered on record creation. It would not be fine for on-demand generation at request time.

Step 1: The OG Image Template

First, create a view that acts as the image canvas. This is a self-contained HTML page with no shared layout, no nav, no footer. It exists purely to be screenshotted.

# app/controllers/open_graph_images/records_controller.rb
class OpenGraphImages::RecordsController < ApplicationController
  layout false

  def show
    @record = Record.find(params[:id])
  end
end

The route:

# config/routes.rb
resources :records, only: [:show] do
  resource :og_image, only: :show, controller: :records, module: :open_graph_images, path: "og-image"
end
# → /records/:record_id/og-image

The view itself is where the design lives. A few things matter here:

<!-- app/views/open_graph_images/records/show.html.erb -->
<!DOCTYPE html>
<html>
<head>
  <link href="https://fonts.googleapis.com/css2?family=Your+Display+Font&family=Your+Body+Font&display=swap" rel="stylesheet">
  <style>
    html, body {
      width: 1200px;
      height: 630px;
      margin: 0;
      padding: 0;
      overflow: hidden;
    }

    /* Your brand colours as CSS custom properties */
    :root {
      --brand-primary: oklch(0.72 0.19 12);
      --brand-dark: oklch(0.25 0.02 280);
    }

    .canvas {
      width: 1200px;
      height: 630px;
      position: relative;
      background: var(--brand-dark);
      font-family: 'Your Body Font', sans-serif;
    }

    .title {
      font-family: 'Your Display Font', cursive;
      font-size: 72px;
      color: white;
    }
  </style>
</head>
<body>
  <div class="canvas">
    <h1 class="title"><%= @record.name %></h1>
    <!-- Layout your card however you want -->
  </div>
</body>
</html>

A few decisions worth explaining:

No shared layout. layout false in the controller means this page doesn’t pull in your application layout. It’s a standalone document. No Tailwind stylesheet, no JavaScript bundles, no nav bar. Just a 1200×630 canvas.

Fixed dimensions in CSS. The html and body elements are hardcoded to 1200×630 with overflow: hidden. There’s no responsive design here. This page will only ever be viewed by a headless browser at that exact size.

Google Fonts loaded inline. The fonts are loaded via a <link> tag directly in the template’s <head>. This matters because we’ll wait for network idle before taking the screenshot, which guarantees the fonts have loaded. Without this, you’d occasionally get screenshots rendered in system fallback fonts.

Live data from ERB. This is a normal Rails view. @record comes from the controller. The image content is generated fresh from the database each time.

You can go as far as you like with the design. Gradients, SVG illustrations, decorative elements, dynamic data from associations. It’s just HTML and CSS.

Step 2: The Screenshot with Ferrum

Ferrum is a pure-Ruby headless Chrome driver. It communicates directly with Chromium via the Chrome DevTools Protocol. No Selenium. No chromedriver binary. No Node.js.

Add it to your Gemfile:

# Gemfile
gem "ferrum"

Why Ferrum over the alternatives? Puppeteer and Satori both require Node.js. Selenium needs a separate chromedriver process. Capybara is designed for testing, not production use. ImageMagick would mean coding the layout without HTML/CSS. Ferrum is lightweight, Ruby-native, and does exactly what we need.

The screenshot logic lives on the model:

# app/models/record.rb
def capture_og_image!
  url = Rails.application.routes.url_helpers.record_og_image_url(
    record_id: id,
    host: ENV.fetch("APP_HOST", "http://localhost:3000")
  )

  browser = Ferrum::Browser.new(
    headless: true,
    window_size: [1200, 630],
    browser_path: ENV.fetch("CHROME_PATH", "/usr/bin/chromium"),
    browser_options: { "no-sandbox": nil, "disable-dev-shm-usage": nil }
  )
  browser.goto(url)
  browser.network.wait_for_idle

  png = browser.screenshot(encoding: :binary)

  og_image.attach(
    io: StringIO.new(png),
    filename: "og-#{id}.png",
    content_type: "image/png"
  )
ensure
  browser&.quit
end

Let me walk through the important bits.

browser.network.wait_for_idle is the key line. Before taking the screenshot, Ferrum waits until the browser’s network activity has gone quiet. This guarantees that Google Fonts (or any other external resources) have finished loading and the page is fully rendered. Without this, you’ll get intermittent screenshots with missing fonts.

ensure browser&.quit makes sure the Chromium process is always cleaned up, even if an exception is raised. Without this, zombie Chrome processes would accumulate on your server. Ask me how I know.

window_size: [1200, 630] opens Chromium at exactly the OG image dimensions, matching the hardcoded CSS. What you see in the browser is exactly what the screenshot captures.

StringIO.new(png) wraps the binary screenshot data so Active Storage’s attach method can treat it like a file upload.

"no-sandbox": nil is required when running inside a Docker container. Chrome’s sandboxing requires kernel privileges that containers don’t have by default. The nil value (rather than "true") is Ferrum’s convention for boolean flags.

Step 3: The Background Job

The job itself is intentionally thin. All the logic lives on the model.

# app/jobs/generate_og_image_job.rb
class GenerateOgImageJob < ApplicationJob
  queue_as :default

  def perform(record_id)
    Record.find(record_id).capture_og_image!
  end
end

Step 4: Triggering Generation Automatically

Use Rails commit callbacks to trigger the job at the right moments:

# app/models/record.rb
after_create_commit :schedule_og_image
after_update_commit :schedule_og_image, if: -> { saved_change_to_name? }

private

def schedule_og_image
  GenerateOgImageJob.perform_later(id)
end

Two things to note here.

after_create_commit rather than after_create. The job runs asynchronously and needs to query the database for the record. If you enqueue the job inside a transaction (before commit), the job might execute before the row is visible to other database connections. after_commit guarantees the transaction has landed before the job fires.

The update callback is scoped. saved_change_to_name? means the image is only regenerated when the display name changes. If someone updates a phone number or description, the job doesn’t fire. The OG image only shows the name and branding, so there’s no reason to regenerate for unrelated field changes.

Step 5: Storage with Cloudflare R2

The generated PNGs are stored using Active Storage backed by Cloudflare R2. R2 is S3-compatible, so Rails’ built-in S3 adapter works without modification.

# config/storage.yml
cloudflare_public:
  service: S3
  endpoint: <%= Rails.application.credentials.dig(:cloudflare_public, :endpoint) %>
  access_key_id: <%= Rails.application.credentials.dig(:cloudflare_public, :access_key_id) %>
  secret_access_key: <%= Rails.application.credentials.dig(:cloudflare_public, :secret_access_key) %>
  region: auto
  bucket: <%= Rails.application.credentials.dig(:cloudflare_public, :bucket) %>
  request_checksum_calculation: "when_required"
  response_checksum_validation: "when_required"

All credentials live in Rails’ encrypted credentials file. Never in source control, never in environment variables that get logged.

Why R2 specifically? No egress fees. Traditional S3 charges per gigabyte of data transfer. OG images get fetched by social media crawlers every time someone shares a link. That transfer volume adds up. R2 eliminates the cost entirely.

Step 6: Public CDN URLs (Bypassing Active Storage’s Signed URLs)

This is the bit that caught me out, and it’s worth its own section. By default, Active Storage generates time-limited signed URLs for S3 blobs. That’s appropriate for private files, but it’s terrible for OG images. Social media crawlers cache aggressively, and a URL that expires in 5 minutes will produce broken previews the next time someone shares the link.

The fix is a custom direct route helper that resolves to the CDN host when one is configured:

# config/initializers/direct_routes.rb
direct :rails_public_blob do |blob|
  cdn_host = Rails.application.credentials.dig(:cloudflare_public, :cdn_host)
  if cdn_host && blob&.key
    File.join(cdn_host, blob.key)
  else
    route = blob.is_a?(ActiveStorage::Variant) ? :rails_representation : :rails_blob
    route_for(route, blob)
  end
end

In production, rails_public_blob_url(@record.og_image.blob) returns something like:

https://assets.yourdomain.com/some-uuid-key.png

A permanent, publicly accessible URL served from Cloudflare’s edge network. No auth tokens, no expiry, no redirect through Rails.

In development, where no CDN host credential exists, it falls back to the standard rails_blob_url helper and serves files through the Rails dev server. No code changes needed between environments.

Step 7: The Meta Tags

On the record’s show page, render the OG image meta tags conditionally:

<% if @record.og_image.attached? %>
  <% og_blob = @record.og_image.blob %>
  <meta property="og:image" content="<%= rails_public_blob_url(og_blob) %>">
  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:image" content="<%= rails_public_blob_url(og_blob) %>">
<% end %>

The blob is assigned to a local variable once rather than called twice. A small thing, but .blob triggers a database read, so there’s no point doing it more than once.

summary_large_image is the Twitter/X card type that renders the image at full width rather than as a small thumbnail.

If no image is attached, the tags are simply absent. Platforms fall back to the page title and description. That’s the correct behaviour.

Step 8: Chromium in Docker

Chromium needs to be available in the production Docker image. Add it to the base stage (not the build stage) so it’s available at runtime:

RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y chromium && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

On Debian-based images, apt installs Chromium to /usr/bin/chromium, which is the default path Ferrum expects. Set your APP_HOST environment variable to your production domain so Ferrum can construct the full URL to navigate to.

Step 9: Bulk Generation

For existing records, or if you redesign the template and want to regenerate everything, a rake task handles it:

# lib/tasks/og_images.rake
namespace :og_images do
  desc "Enqueue OG image generation for all records"
  task generate: :environment do
    ids = Record.all.ids
    puts "Enqueueing OG image generation for #{ids.count} records..."
    jobs = ids.map { |id| GenerateOgImageJob.new(id) }
    ActiveJob.perform_all_later(jobs)
    puts "Done."
  end
end
rails og_images:generate

Record.all.ids loads only the primary key column, not full ActiveRecord objects. For hundreds or thousands of records, that matters.

ActiveJob.perform_all_later(jobs) enqueues all jobs in a single bulk operation rather than N individual database inserts. If you’re using Solid Queue (Rails’ built-in database-backed job queue), this is a single bulk INSERT rather than one per record.

A Gotcha: UUID Primary Keys and Active Storage

If your app uses UUID primary keys, be aware that Active Storage’s install migration (rails active_storage:install) generates tables with standard integer IDs. If every other table in your app uses UUIDs, Active Storage’s tables need to match.

The fix is to edit the generated migration before running it, swapping the default integer primary key for your UUID type on all three Active Storage tables (active_storage_blobs, active_storage_attachments, active_storage_variant_records). The lesson: when you adopt a project-wide convention for primary keys, any engine-generated migrations need to be reviewed and updated to match before you run them.

Trade-offs

Speed. Each image takes a few seconds to generate. A headless browser is heavy. This rules out on-demand generation at request time. But for a background job triggered once on create (and occasionally on update), it’s perfectly fine.

Memory. Each job spins up its own Chromium instance. At small scale that’s nothing. At high concurrency you’d want to think about throttling or a shared browser pool.

Simplicity. This is the real win. You design the image in HTML and CSS. You use your real fonts, your real colours, your real data. Any change to the template is a change to an ERB file. No learning a new image generation API. No maintaining a separate Node.js service.

Storage cost. R2 has no egress fees. The images are small PNGs (typically under 200KB). Storage cost is essentially zero for any reasonable number of records.

Failure mode. If the job fails (Chromium crashes, the page 500s), the record simply doesn’t have an OG image attached. The meta tags aren’t rendered. Platforms fall back to text-only previews. The job retries according to your queue’s retry policy. Nothing breaks.

Ship It

The full stack: an ERB template for the design, Ferrum for the screenshot, Active Storage for the attachment, Cloudflare R2 for public hosting, and a custom direct route to bypass signed URLs. No Node.js, no third-party image service, no monthly bill.

Your OG images are version-controlled HTML. Your designers can modify them with CSS. Your data comes straight from the database. And every shared link gets a polished preview card instead of a blank box.