How to store images in Hugo to Cloudflare R2

Hugo
How to store images in Hugo to Cloudflare R2

TABLE OF CONTENTS


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

  1. Create a Cloudflare account and navigate to R2 Object Storage
  2. Create a bucket (e.g. my-blog-images)
  3. Enable Public Access in the bucket settings to get a public URL like: https://pub-xxxxx.r2.dev/
  4. Generate an Account API Token with read/write permissions for the bucket
  5. Configure the AWS CLI with an r2 profile:
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:

  1. Resizes each image so the longest dimension is capped at 3000px (using sips)
  2. Converts to WebP at quality 85 for the full-res and quality 50 for the thumbnail (using cwebp)
  3. Deletes the original heavy .jpg/.png files
  4. 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.