Foreword
Storing high-resolution photos in Git expected to full up my repository and slowed down page loads 😿
This post documents how I migrated to Cloudflare R2 and built an automated workflow to optimize and upload images with a single command.
I was surprised by how user-friendly R2 is to use — it’s clear why it’s so popular, even beyond its “interesting” reputation among threat actors 😂
The entire technical migration — including scripts, shortcode refactoring, and the WebP pipeline — was implemented by Antigravity, Google DeepMind’s AI coding assistant. I provided the requirements and reviewed each step, but Antigravity wrote and executed all the code.
Setting Up R2
- Create a Cloudflare account and navigate to R2 Object Storage
- Create a bucket (e.g.
my-blog-images) - Enable Public Access in the bucket settings to get a public URL like:
https://pub-xxxxx.r2.dev/ - Generate an Account API Token with read/write permissions for the bucket
- Configure the AWS CLI with an
r2profile:
aws configure --profile r2
# Access Key ID: (from Cloudflare R2 API token)
# Secret Access Key: (from Cloudflare R2 API token)
# Region: auto
Centralized Configuration
To manage the CDN endpoint efficiently, I stored the Cloudflare R2 URL in the hugo.toml configuration:
[params]
cdnURL = "https://pub-xxxxx.r2.dev/"
Custom Hugo Shortcode
I created a file layouts/shortcodes/img.html as a custom shortcode img that pulls this cdnURL and automatically prepends it to image paths.
{{ $src := .Get 0 }}
{{ $cdnURL := .Site.Params.cdnURL }}
{{ $full_src := $src }}
{{ if not (hasPrefix $full_src "http") }}
{{ $full_src = print $cdnURL $full_src }}
{{ end }}
This keeps the Markdown clean and ensures that if I ever switch providers, I only need to update one line in my config:
Usage in Markdown:
{{< img "my-post/photo.webp" "A nice caption" >}}
Front Matter & Cover Images
Integrating R2-hosted images as post covers (the image: field in YAML) required a different approach since Hugo doesn’t support shortcodes in front matter.
Example of front matter:
---
title: 'Blog Title'
date: '2026-01-01T00:00:00+00:00'
description:
draft: False
image: my-post/cover.webp
tags:
categories:
- Diary
---
I updated my theme’s layouts (index.html and single.html) to automatically detect relative paths.
{{ $image := "" }}
{{ if .Params.image }}
{{ if (hasPrefix .Params.image "http") }}
{{/* Use absolute URL as is */}}
{{ $image = .Params.image }}
{{ else if .Resources.GetMatch .Params.image }}
{{/* Use local file if it exists in the post folder */}}
{{ $image = (.Resources.GetMatch .Params.image).RelPermalink }}
{{ else if .Site.Params.cdnURL }}
{{/* Prepend Cloudflare R2 URL for everything else */}}
{{ $image = print .Site.Params.cdnURL .Params.image }}
{{ else }}
{{/* Fallback to standard relative URL */}}
{{ $image = .Params.image | relURL }}
{{ end }}
{{ end }}
If an image isn’t found in the local repository, the theme prepends the cdnURL automatically. This allows me to keep the front matter simple and clean:
Dual-Resolution Loading
To improve page performance, the shortcode actually serves two versions of each image:
- Thumbnail (
photo_thumb.webp) — 1200px, quality 50 — loaded immediately on page render - Full resolution (
photo.webp) — 3000px, quality 85 — loaded only when the user clicks to zoom
The shortcode automatically generates the _thumb URL from the original filename:
This logic is in these two files:
layouts/shortcodes/img.html: For images inside post content.{{/* Extract path without extension and extension separately to build thumb URL */}} {{ $ext := path.Ext $full_src }} {{ $base := strings.TrimSuffix $ext $full_src }} {{ $thumb_src := printf "%s_thumb%s" $base $ext }}layouts/_default/single.html: For the featured/cover image at the top of the post.
The Upload Script
I wrote a bash script (equips/upload-publish.sh) that handles the entire pipeline. When I finish writing a post, I just run:
./equips/upload-publish.sh my-new-post
The script automatically:
- Resizes each image so the longest dimension is capped at 3000px (using
sips) - Converts to WebP at quality 85 for the full-res and quality 50 for the thumbnail (using
cwebp) - Deletes the original heavy
.jpg/.pngfiles - Uploads both versions to Cloudflare R2 (using
aws s3 sync)
Keeping Images Out of Git
To make sure images never accidentally get committed, I added these lines to .gitignore:
content/**/*.jpg
content/**/*.jpeg
content/**/*.png
equips/
The equips/ folder itself is also ignored since it contains credentials-dependent scripts that shouldn’t be shared publicly.
Results
After migrating ~250 images:
- Repository size dropped dramatically (no more heavy binary files in Git history)
- Page load times improved significantly with the thumbnail → full-res loading strategy
- Publishing workflow became simpler — just drop photos in the folder and run one command
This setup was not completed only by free plan. I subscribed to Google API Pro to use Antigravity, but the monthly cost is reasonable 🙂
Before this, I struggled with Hugo theme changes and found infrastructure work especially painful. With Antigravity, I was able to get it all done just by describing what I wanted.