· Zerdalu
Go Back

Handcrafted Dynamic OG Images with ImageMagick and Cloudflare R2

Whenever you paste a link into Twitter, LinkedIn, or iMessage, the platform unfurls it into a rich preview card. This preview is powered by Open Graph (OG) images. For a long time, I wanted to create a system that automatically generates these images for my blog posts, but I refused to settle for the generic, programmatic look that plagues so many developer blogs.

I wanted something that looked handcrafted, editorial, and perfectly aligned with the aesthetic of Zerdalu.

Instead of relying on heavy Node.js libraries or paid services, I decided to build a lightweight, fully automated pipeline using Bash, ImageMagick, and Cloudflare R2. Here is a comprehensive guide on how we achieved this.

Step 1: The Canvas and the Bird

Before writing any code, I needed a beautiful base template. I used Inkscape to design a 1200x630 canvas (the standard resolution for OG images).

To give it a vintage, naturalistic feel, I sourced a gorgeous public domain illustration of birds on a tree branch.

Credit where it’s due: The illustration is a photograph of an artwork provided by the National Gallery of Art on Unsplash.

In Inkscape, I placed the illustration on the right side of the canvas and defined a precise bounding box (a dashed rounded rectangle) on the left side where our dynamic text would eventually sit. I exported this as a simple PNG file (og-zerdalu-kip.png).

Step 2: The Tools

I run Debian, so setting up the environment was straightforward. The heavy lifting for image manipulation is done by ImageMagick, an incredibly powerful command-line tool for image processing.

To install ImageMagick on Debian/Ubuntu, simply run:

sudo apt update
sudo apt install imagemagick

We also utilize the Wrangler CLI (which is already installed in our Astro project) to seamlessly push the generated images to our Cloudflare R2 bucket.

Step 3: The Bash Script

We created a shell script (generate-og.sh) that runs locally after our site is built. The script’s job is to:

  1. Scan our src/blog directory for Markdown files.
  2. Extract the frontmatter (Title, Description, Date, Author).
  3. Generate the typography using our custom fonts.
  4. Composite the text onto our Inkscape template.
  5. Upload the final image to Cloudflare R2.

Extracting Frontmatter with AWK

Instead of parsing YAML with a heavy dependency, we can use awk to cleanly extract variables directly from the Markdown files:

title=$(awk -F':' '/^title:/ {sub(/^title:[ \t]*/, ""); sub(/^"/, ""); sub(/"[ \t]*$/, ""); print; exit}' "$file" | tr -d '\r')

The Magic Trick: Dynamic Font Sizing

The biggest challenge with programmatic images is text overflow. A post titled “Understanding Big O” takes up very little space, but “Building a High-Performance SSO Engine on Cloudflare Workers” might bleed right off the canvas.

To fix this, we wrote a while loop that attempts to render the text at an ideal size (64pt). If ImageMagick reports that the resulting text block is taller than our allowed bounding box, the script proportionally shrinks the font size and tries again until it fits perfectly.

Here is the core ImageMagick logic:

TITLE_SIZE=64
DESC_SIZE=32
MAX_HEIGHT=350 

while true; do
    # Generate the Title and Description block
    magick -background none -gravity NorthWest \
        \( -font "fonts/IMFellEnglish-Regular.ttf" -pointsize $TITLE_SIZE -fill "#1A1513" -size 652x caption:"${title}" \) \
        \( -size 652x32 xc:none \) \
        \( -font "fonts/IMFellEnglish-Regular.ttf" -pointsize $DESC_SIZE -fill "#4D433D" -size 652x caption:"${description}" \) \
        -append "$TMP_DIR/main_block.png"

    # Measure the height of the generated block
    HEIGHT=$(magick identify -format "%h" "$TMP_DIR/main_block.png")

    # Break the loop if it fits
    if [ "$HEIGHT" -le "$MAX_HEIGHT" ] || [ "$TITLE_SIZE" -le 36 ]; then
        break
    fi

    # Otherwise, shrink the fonts and try again
    TITLE_SIZE=$((TITLE_SIZE - 4))
    DESC_SIZE=$((DESC_SIZE - 2))
done

Compositing and Placement

Once the text blocks are generated, we anchor the meta-information (Author and Date) to the Top-Right (NorthEast), and our dynamically sized Main Title block to the Bottom-Left (SouthWest) of our bounding box.

magick "scripts/og-zerdalu-kip.png" \
    \( -size 732x518 xc:none \
       "$TMP_DIR/meta_block.png" -gravity NorthEast -geometry +40+40 -composite \
       "$TMP_DIR/main_block.png" -gravity SouthWest -geometry +40+40 -composite \
    \) \
    -gravity NorthWest -geometry +56+56 -composite "$img_path"

Step 4: Uploading to Cloudflare R2

Instead of writing a complex cURL request and calculating AWS Signature V4 hashes manually, we lean on Wrangler. Because our project is already authenticated with Cloudflare, uploading the image is a one-liner:

npx wrangler r2 object put "<your-bucket-name>/blog/og/$slug.png" --file "$img_path" --remote

(Note the --remote flag, which ensures Wrangler targets the actual Cloudflare infrastructure rather than a local emulator).

Step 5: Tying it together in Astro

Finally, we update our package.json to run this script right after deploying:

"deploy": "astro build && wrangler deploy && npm run og"

And in our Astro Layout (blog-layout.astro), we dynamically inject the Open Graph meta tags pointing to our R2 bucket:

<meta property="og:image" content={`https://<domain-of-your-bucket>/blog/og/${post.id}.png`} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />

Conclusion

By combining the raw power of Bash, the image manipulation capabilities of ImageMagick, and the seamless deployment of Cloudflare R2, we now have a completely free, lightning-fast OG image generator.

The result is a set of social cards that don’t look generated at all—they look like they were typeset by hand for a magazine. And best of all, I never have to manually create an OG image again.