Hugoブログの画像をCloudflare R2で管理する|構成と運用ルール

Hugoブログの画像をCloudflare R2で管理する|構成と運用ルール
この記事の概要

このブログ(Hugo + Cloudflare Pages)では、記事で使う画像をGitリポジトリに入れず、Cloudflare R2に置いてURLで参照しています。

この記事は、そうすることにした理由と、今の構成・運用ルールの記録です。R2バケットの作成やカスタムドメインの設定手順そのものは扱っていません。2026年9月に、現在の構成に合わせて内容を見直しました。

最初は画像もリポジトリに入れればいいと思っていた

HugoはMarkdownと画像をまとめてGitHubで管理できるのが強みのひとつです。記事ごとにフォルダを作り、その中に index.md と画像をまとめて置く「Page Bundle」という構成が使えるので、記事と素材の対応が分かりやすくなっています。

content/posts/
  2026-05-01-my-article/
    index.md
    cover.webp
    diagram.png

この構成は直感的で、最初はこれで十分だと思っていました。

ところが記事が増えていくにつれて、アイキャッチ画像・サムネイル・OGP画像を毎回用意するようになります。1記事あたり3〜5枚の画像が増えていくと、リポジトリのサイズがじわじわ膨らんでいくのではないかという、不安に駆られました。まだ、ほとんど記事書いてないですけど。Markdownファイル本体は数KBなのに、画像だけで数MBという状況はよくないのでは?と思うようになりました。

GitHubリポジトリが重くなるのが気になった

Gitは、テキストに限らず、コミットしたファイルのバージョンを履歴としてすべて残します。似たファイル同士は差分だけを保存して容量を抑える仕組み(パックファイル)もありますが、アイキャッチを別の画像に差し替えたような場合は、古い画像も新しい画像も、ほぼ1枚分ずつの大きさで履歴に残ります。

通常の git clone では、この履歴をすべて取得します(--depth を付けて最近の履歴だけを取る方法もあります)。記事を書くたびに画像を足していくと、年単位では無視できない大きさになりそうだと思いました。

Cloudflare Pagesのビルドでも、その時点のファイル一式を取得してからビルドします。Hugoのドキュメントによると、Cloudflare Pagesは標準で最近の履歴だけを取る浅いクローンなので、過去の画像の履歴まで毎回取り直すわけではありません。それでも、リポジトリに置いた画像はビルドのたびに取得されるので、画像が増えればビルド時間に影響する可能性があります。

「ブログ本文の変更履歴」と「画像アセットの保管場所」は、そもそも性質が違います。そう気づいてから、分けて管理したほうがすっきりすると思いました。

Cloudflare R2を画像置き場にした

Cloudflare R2はCloudflareが提供するオブジェクトストレージです。Amazon S3と互換性のあるAPIを持ちつつ、インターネットへのデータ転送(エグレス)料金がかからないのが特徴で、個人ブログの画像配信には手頃な選択肢です。

料金は、保存している容量と、書き込み・読み込みなどの操作回数で決まります。2026年9月18日に公式の料金ページで確認した内容(Standardストレージ)は次のとおりです。

項目 毎月の無料枠 無料枠を超えた分
ストレージ 10 GB-month $0.015 / GB-month
Class A操作(書き込みなど) 100万回 $4.50 / 100万回
Class B操作(読み込みなど) 1,000万回 $0.36 / 100万回
エグレス(データ転送) 無料 無料

このブログの画像は1記事あたり数枚なので、ストレージの10GBに届くことは当面なさそうです。読み込みの1,000万回は、1日あたり約33万回に相当します。1ページを開くと画像を何枚か読み込むので、ページの表示回数とそのまま比べることはできませんが、それでもこのブログには当然そんなアクセスはありません。そこまでアクセスがあれば、もうブログで飯が食えています。今の規模なら、当面は無料枠の中でやっていけそうです。

ただし、無料枠は月ごとの上限で、超えた分は使った量に応じて課金されます。公式ページには、10万ファイルを1日1,000万回読み出す画像配信の試算も載っていて、その場合は読み込みだけで月104.40ドルになっていました。アクセスが増えれば費用も増えるので、使う前に最新の料金を確認してください。

このブログはCloudflare Pagesで配信しているので、Cloudflare側に画像も寄せると運用がまとまりやすくなります。R2バケットにカスタムドメインを設定することで、画像URLを https://images.keyuki.net/... という形で統一できます。R2には開発用の r2.dev のURLもありますが、公式ドキュメントではレート制限があり開発用途向けとされているので、公開用にはカスタムドメインを使っています。

記事のフロントマターには画像のURLだけを書き、記事用の画像ファイルはGitに登録しません。

featured_image: https://images.keyuki.net/uploads/2026/05/sample-eyecatch.webp
thumbnail_image: https://images.keyuki.net/uploads/2026/05/sample-thumb.webp
images:
  - https://images.keyuki.net/uploads/2026/05/sample-og.jpg

Hugo側ではURLを参照するだけにした

リポジトリに残るのは、記事本文・テンプレート・CSS・設定ファイルが中心です。ただし、既定のOGP画像のようなサイト共通の画像は、今も static/images/ に置いています。R2に移したのは、記事ごとに増えていく画像です。作業中の元画像や変換後のファイルを手元の uploads/ フォルダに置いておくこともありますが、これもGitには登録していません。

フロントマターの画像フィールドは、役割ごとに3つに分けています。

フィールド 使う場所 項目名の出どころ
featured_image 記事ページのアイキャッチ Ananke テーマ
thumbnail_image トップ・一覧の小さな画像 このブログ独自
images SNSのカード画像(OGP・Xカード) Hugo標準のテンプレート

項目名はテーマやHugoの慣習に合わせていますが、このブログでは3つとも自前のテンプレートで読み取っていて、指定がないときに代わりに使う画像も自分で決めています。

  • 一覧の画像:thumbnail_image → なければ featured_image(未設定なら本文の最初の画像)
  • SNSカードの画像:images の1枚目 → なければ featured_image(同上)→ それもなければサイト共通の既定画像

同じ項目名でも、テーマや自作のテンプレートによって使われ方は変わります。別のテーマで同じことをするときは、テンプレート側がどの項目を読んでいるかを先に確認してください。

R2の画像は外部のURLなので、このブログのテンプレートはビルド時に画像の縦横サイズを読みに行きません。そこで2026年9月からは、フロントマターに featured_image_width と featured_image_height を書いた記事だけ、記事ページの画像に幅と高さの属性を付けています。ブラウザが先に表示枠を確保できるので、読み込み中の表示のずれを抑えられます。

featured_image_width: 1200
featured_image_height: 669

thumbnail_image を分けた理由はPageSpeed改善の記事に、images を分けた理由はXのリンクカードの記事に書きました。

少し管理は面倒になりましたが、快適にサイトが見れるようにやっていくには、これがいいと今のところ思ってます。

よかったこと

記事用の画像がリポジトリに入らないので、記事を増やしてもリポジトリが大きくなりにくいのが一番の効果でした。

  • 画像を差し替えてもGit履歴に古いファイルが積み上がりません。R2上で上書きすれば済みます(キャッシュには注意が必要です。後述します)。
  • アイキャッチ・サムネイル・OGPと用途別の画像を整理して置きやすくなりました。

気をつけること

移行してよかった一方で、いくつか注意点もあります。

R2側のURL設計が後から変えにくい。 記事から参照しているURLを変えると、過去記事の画像が壊れます。最初からディレクトリ構造とファイル命名規則を決めておく必要があります。

画像を消すと記事の表示が壊れる。 R2は、S3にあるようなバージョン管理(バージョニング)に対応していません(2026年9月時点の公式ドキュメント)。Gitのように過去の版が自動で残るわけではないので、上書きや削除をした画像は、手元に元のファイルがなければ戻せません。不要になった画像でも、参照している記事がないか確認してから消す必要があります。使わなくなった古い記事の画像をまとめて削除したときも、先に content/ を検索して、その画像のURLを参照している記事が残っていないことを確かめてから消しました。

GitHubだけ見ても画像の実体がわからない。 リポジトリを見ても画像は見えません。バックアップや棚卸しは別途R2側で行う必要があります。

キャッシュと公開設定に注意する。 R2バケットのパブリックアクセス設定やCloudflareのキャッシュ設定を間違えると、画像が表示されなかったりキャッシュが意図せず残ったりします。

「R2に置けば解決」ではなく、運用ルールをセットで決めることが大事だと感じました。

今の運用ルール

実際にやっていることをまとめると、こんな感じです。

ディレクトリ構成: 記事の date の年月で分けています。

uploads/
  2026/
    05/
      article-slug-eyecatch.webp
      article-slug-thumb.webp
      article-slug-og.jpg

ファイル命名規則: 記事のslugをベースに、用途をsuffixで区別します。

用途 ファイル名の例 形式とサイズの目安
記事ページ用アイキャッチ slug-eyecatch.webp WebP、幅1200px
一覧サムネイル用 slug-thumb.webp WebP、760×427px(16:9)
SNSカード用 slug-og.jpg JPEG、1200×630px

このルールを決める前に書いた記事には、PNGのアイキャッチのままのものも残っています。

画像を追加するときの流れ

  1. 元画像1枚から、アイキャッチ・サムネイル・SNSカード用の3種類をスクリプトで書き出す
  2. 3つのファイルを wrangler でR2にアップロードする
  3. フロントマターに3つのURLを書く
  4. curl -I などで3つのURLがHTTP 200と正しい Content-Type を返すことを確認し、git status で画像ファイルが変更に入っていないことを確かめる

アップロードは次のような形です。

wrangler r2 object put <バケット名>/uploads/2026/05/slug-thumb.webp \
  --file ./slug-thumb.webp \
  --content-type image/webp \
  --remote

--content-type は毎回指定しています。最初にまとめて画像をR2へ移したとき、付けないと application/octet-stream として保存され、ブラウザが画像として表示せずダウンロード扱いにすることが分かったからです。--remote も欠かせません。最初はこれを付け忘れて、画像がR2ではなく手元のローカル環境に保存されていたことに気づかず、アップロードし直しました。

まとめ

HugoはGitHubで記事を管理しやすいですが、画像まで全部GitHubに入れる必要はありません。

「本文・テンプレート・設定はGitHub、記事の画像はR2」という分担にしたことで、記事を増やしてもリポジトリが重くなりにくくなりました。その代わり、画像のURL・削除・バックアップは、Gitの外で自分で管理することになります。ブログ運用では「どこに置けるか」より「後から重くならないか」を考えたほうが長続きすると思います。

画像が増えてから整理しようとすると、URL変更による過去記事の破損リスクが出てきます。早めに置き場所と命名ルールを決めておくのが一番楽です。