How I Generate Custom OpenGraph Images During Rails Deploys With Python and Ansible
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.