Most Rails apps do not bother with a custom OpenGraph image. Mine do and they are generated automatically during deployment using Python, Cairo and Ansible.

It started as a small itch. I was tired of seeing generic previews when links from my app were shared on Nostr or LinkedIn. I wanted something dynamic, branded and beautiful. But I did not want to add another manual step. So I baked it straight into the deployment process.

Here is how.

The Goal

When I deploy my Rails apps, I want a fresh opengraph.png to be generated using the current logo and a few brand colors. The PNG needs to be created only if a logo SVG is present.

The task should run right before assets are precompiled, headless, fast and integrated.


Python Script: generate-opengraph.py

This is where the magic happens. The script creates a smooth background using Cairo, adds a branded arc and embeds a white version of the app’s logo in the center of a circle.

import cairocffi as cairo
import sys
import uuid
import os
from PIL import Image
import cairosvg

def create_smooth_custom_image(svg_path, output_path, top_color="#f5f5f5", bottom_color="#ffffff", circle_color="#ff0000"):
    width, height = 1600, 900
    surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
    ctx = cairo.Context(surface)

    ctx.rectangle(0, 0, width, height)
    ctx.set_source_rgb(*hex_to_rgb(bottom_color))
    ctx.fill()

    ctx.translate(width / 2, height / 2)
    ctx.rotate(3.1416)
    ctx.translate(-width / 2, -height / 2)

    ctx.move_to(0, height * 0.55)
    ctx.curve_to(width * 0.15, height - height * 0.45, width * 0.75, height - height * 0.65, width, height - height * 0.35)
    ctx.line_to(width, height)
    ctx.line_to(0, height)
    ctx.close_path()
    ctx.set_source_rgb(*hex_to_rgb(top_color))
    ctx.fill()

    circle_radius = 300
    circle_center = (width // 2, height // 2)
    ctx.arc(circle_center[0], circle_center[1], circle_radius, 0, 2 * 3.1416)
    ctx.set_source_rgb(*hex_to_rgb(circle_color))
    ctx.fill()

    temp_path = f"/tmp/output_{uuid.uuid4()}.png"
    surface.write_to_png(temp_path)

    svg_output_path = f"/tmp/logo_{uuid.uuid4()}.png"
    cairosvg.svg2png(url=svg_path, write_to=svg_output_path, output_width=400, output_height=400)
    logo = Image.open(svg_output_path).convert("RGBA")

    white_logo = Image.new("RGBA", logo.size, (255, 255, 255, 0))
    for x in range(logo.width):
        for y in range(logo.height):
            pixel = logo.getpixel((x, y))
            if pixel[3] > 0:
                white_logo.putpixel((x, y), (255, 255, 255, pixel[3]))

    result = Image.open(temp_path).convert("RGBA")
    logo_position = (circle_center[0] - logo.width // 2, circle_center[1] - logo.height // 2)
    result.paste(white_logo, logo_position, mask=white_logo)

    result.save(output_path)
    os.remove(svg_output_path)
    os.remove(temp_path)
    print(f"Image saved to {output_path}")

def hex_to_rgb(hex_color):
    hex_color = hex_color.lstrip("#")
    return tuple(int(hex_color[i:i + 2], 16) / 255.0 for i in (0, 2, 4))

if __name__ == "__main__":
    svg_path = sys.argv[1]
    output_path = sys.argv[2]
    info_color = sys.argv[3]
    top_color = sys.argv[4]
    bottom_color = sys.argv[5]
    create_smooth_custom_image(svg_path, output_path, top_color=top_color, bottom_color=bottom_color, circle_color=info_color)

Ansible Task: generate_opengraph.yml

This snippet checks if the SVG file exists and runs the Python script only if it is present:

- name: Check if SVG file exists
  stat:
    path: "files/{{ inventory_hostname }}/app/app/assets/images/logo.svg"
  register: svg_file_stat

- name: Execute Python script to create Opengraph png image
  command: >
    python3 generate-opengraph.py
      files/{{ inventory_hostname }}/app/app/assets/images/logo.svg
      files/{{ inventory_hostname }}/app/app/assets/images/opengraph.png
      {{ INFO_COLOR }}
      {{ HIGHLIGHT_SECONDARY_COLOR }}
      {{ LIGHT_COLOR }}    
  when: svg_file_stat.stat.exists

This gets bundled into my main playbook that provisions and deploys the Rails app. The values for INFO_COLOR, HIGHLIGHT_SECONDARY_COLOR and LIGHT_COLOR are passed via group_vars.


Why Bother?

I am a fan of small touches. OpenGraph images make links stand out. They tell people you care about presentation and polish. And when it takes 2 seconds during deploy? That is ROI.

Sure, I could use a static image. But where is the fun in that?


Next Steps

I am considering generating multiple formats for platforms like X (Twitter), LinkedIn and Discord, maybe even inject text dynamically using Pillow.

For now, this is fast, elegant and works well with my CI/CD pipeline.


Pro tip: If you ave got SVG logos sitting around and an Ansible deploy, there is no excuse not to brand your OpenGraph.

Let the robots draw it for you.