Astro + Cloudflareでこのブログを作った


この記事は、構成と文章の整理にAIを使っています。
あくまで読みやすくするための補助で、内容そのものをAIにすべて任せて書いているわけではありませんが、ところどころ相談しながら整えています。

記念すべき最初の記事は、このブログ自体をどう作ったかにした。ベタだけど、構築直後の記憶が一番新しいうちに残しておきたい。あと、同じことをやろうとしている人の地雷除去にもなると思うので。

何が欲しかったのか

要件はシンプルだった。

  • 無料で公開できる
  • メンテナンスフリー(サーバーの面倒を見たくない)
  • いずれ広告を載せて収益化したい(儲けが目的ではなく、運用費を賄えたらいいな程度)

この3つを満たすなら、答えはだいたい決まっていて、静的サイト(SSG)一択になる。DBもサーバーもないから、そもそも壊れる場所がない。これがメンテフリーの本質だと思う。

技術選定でちょっと寄り道した

EmDashが気になった、けど見送った

最初、Cloudflareが出しているAstroベースのCMS「EmDash」が気になっていた。が、調べたらまだプレビュー段階で、しかもサーバーを動かしてエッジコンピュートに課金して……という構成だった。「メンテフリーが欲しい」と言っている人間が選ぶものじゃない。素直に見送り。触ってみたい欲だけはまだある。

結局:Astro + Cloudflare Workers (Static Assets)

普段からTypeScriptを書いているのでAstroとの相性は良いし、Markdownで記事を書いてgit pushするだけで公開できる。ホスティングはCloudflare。これで決まり。

作る

ここはあっけないくらい簡単だった。

ターミナル
npm create astro@latest # Blogテンプレート、TypeScript Strict
cd my-blog
npm run dev

http://localhost:4321 を開いたら、もうブログの雛形が表示されている。あとはGitHubにリポジトリを作ってpush。ここまでは何も詰まらなかった。

問題はこの先で起きた。

ハマり①:画像が表示されない(/_image が404)

Cloudflareにデプロイして公開URLを開いたら、画像だけ表示されない。F12のNetworkタブを見ると、こんなリクエストが404になっていた。

/_image?href=%2F_astro%2Fblog-placeholder-2.xxxx.jpg&w=720&h=360&f=webp

ローカルの npm run dev では普通に出ていたのに、本番だけ出ない。

原因

/_image は、Astroがdev時やSSR時に画像を実行時変換するためのエンドポイント。逆に、静的ビルドのときは <Image> がビルド時に画像を最適化して dist/_astro に吐き出すので、本来このエンドポイントは登場しない。

つまり、HTMLに /_image?... が残っているということは、ビルド時最適化が効かず、実行時エンドポイント前提のURLが焼き込まれている状態だった。

犯人はデプロイ方法。最初、プロジェクトがSSR Workerとしてデプロイされていた(公開URLが .workers.dev だったのがヒント)。Workersはオンデマンド実行が前提の世界なので、静的最適化済みの画像とは噛み合っていなかった。

切り分け

ビルドが悪いのか配信が悪いのかを、手元で分けて確認した。

ターミナル
npm run build
grep -rl "/_image" dist/ # → 何も返らない=HTMLに/_imageは無い
npm run preview # → 画像が表示される

dist のHTMLに /_image は無く、preview(本番ビルドの静的配信)では画像が出た。これで「ビルドは正しい、配信側が悪い」と確定。

解決

純粋な静的配信になるように、リポジトリ直下に wrangler.jsonc を置いた。

{
"name": "my-blog",
"compatibility_date": "2026-06-26",
"assets": {
"directory": "./dist",
"not_found_handling": "404-page"
}
}

ポイントは、main も binding も書かないこと。Workerスクリプトを持たせず、静的アセットだけを配信する形にするのがコツだった。これで dist/_astro の最適化済み画像が /_astro/xxx.webp の直リンクで配信され、無事に表示された。

ちなみにAstroの設定(output: static がデフォルト、アダプタ無し、sharpあり)は最初から正しかったので、astro.config 側は一切いじっていない。完全に配信の問題だった。

ハマり②:Cloudflareの「Pages」が見つからない

「じゃあ普通のCloudflare Pagesで静的配信すればよかったのでは」と思って作り直そうとしたら、Pagesの新規作成の入口が見当たらない。「Create application」を押してもWorker作成のウィザードが立ち上がる。

これは自分のミスではなく、CloudflareがPagesをWorkersに統合する方向に舵を切っているためだった。今は新規作成がWorkers寄りになっていて、Free/ProプランだとサブドメインだけをCloudflareゾーンにする「subdomain setup」も使えない(あれはEnterprise限定)。

なので無理にPagesを探すのはやめて、Workers + Static Assetsで素直に組む方針に切り替えた。将来的にもCloudflareはこっちに寄せていくので、結果的に筋の良い選択だったと思っている。

ドメイン戦略:親を載せるか問題

blog.hoge.dev というサブドメインで公開したかった。Workersのカスタムドメインは「そのドメインのDNSをCloudflareが管理していること」が条件になる。

最初は「親の hoge.dev のネームサーバーは動かしたくない、blog だけCloudflareに委任したい」と思っていた。が、前述の通りそれはEnterprise限定。

そこで考え方を変えた。自分はこれから hoge.dev 配下に自分用のツールをいくつも作っていきたい。tool.hoge.dev、api.hoge.dev みたいに。だったら、hoge.dev を常設の実験場ドメインとして、親ごとCloudflareに載せてしまうほうが圧倒的に取り回しがいい。

  • サブドメインを好きなだけ生やせる
  • 1サブドメイン = 1プロジェクト(Worker / Pages)を自由に割り当てられる
  • DNSレコードも証明書もCloudflare内で自動完結
  • 自分用ツールにはCloudflare Accessで認証の壁を無料で付けられる

「親NSを動かす」のは所有権の移管ではなく、DNSの管理元をCloudflareにするだけ。レジストラはそのまま。失うものは実質なかった。

ネームサーバーを切り替えて、DNSの**伝播(でんぱ)**を待ち(.dev は比較的速い)、Activeになったところでカスタムドメインを追加。blog.hoge.dev で無事に公開できた。

余談:「伝播」をずっと「でんばん」だと思っていた。正しくは「でんぱ」。電波と同じ音。気づけてよかった。

ドメインを繋いだら astro.config の site を実ドメインに直すのも忘れずに。RSSやsitemap、canonical URLがこれを基準に生成されるので。

site: 'https://blog.hoge.dev',

おまけ:URLパスを整える(任意)

これは完全に好みの問題なので、気にならない人は読み飛ばしていい。

Astroのblogテンプレートは、デフォルトだと記事のURLが /blog/記事名/ になる。普通のドメインならこれで何も問題ない。でも自分の場合、ドメイン自体が blog.hoge.dev なので、そのままだと blog.hoge.dev/blog/記事名/ になって「blog」が二重になる。ちょっとだけ気持ち悪い。

なので /blog/ を /articles/ に変えた。やることはシンプルで、ルーティングを担っている src/pages/blog/ ディレクトリをリネームするだけ。

ターミナル
git mv src/pages/blog src/pages/articles

ここで一つ注意。記事の実体が入っている src/content/blog/(コレクション名)はURLとは無関係なので、触らない。[...slug].astro の中の getCollection('blog') の 'blog' もコレクション名なので、そのまま。変えるのは src/pages/ 側のディレクトリだけ。

あとは内部リンクとRSSの /blog/ を置換して、取りこぼしがないか確認すれば終わり。

ターミナル
grep -rn "/blog" src/ # 内部リンク・rss.xml.js などを洗い出して置換
npm run build
grep -rn "/blog" dist/ # distに /blog が残っていなければ漏れなし

繰り返すけど、ここは完全にオプション。サブドメインを blog. にしている人の、見た目のこだわりみたいなものです。

最終的な構成

flowchart TD
  A["Markdown (GitHub管理)"] -->|git push| B["Astro(静的ビルド / dist)"]
  B -->|wrangler.jsonc で静的アセット配信| C["Cloudflare Workers (Static Assets)"]
  C -->|Custom Domain| D["blog.hoge.dev(TLS自動)"]

  classDef source fill:#E8EAF6,stroke:#3F51B5,color:#1A237E
  classDef build fill:#E3F2FD,stroke:#1976D2,color:#0D47A1
  classDef host fill:#E8F5E9,stroke:#388E3C,color:#1B5E20
  classDef publish fill:#FFF3E0,stroke:#F57C00,color:#E65100

  class A source
  class B build
  class C host
  class D publish

CMSは入れていない。記事はリポジトリの .md をそのまま管理して、エディタはVS Code + Astro公式拡張。Content CollectionsのZodスキーマからfrontmatterが補完・検証されるので、TypeScriptと同じ「型に守られながら書く」感覚で記事も書ける。これが個人的にはすごく快適。

これからやること

  • 記事を書く(AdSense審査の前提でもある)
  • Google Analytics(partytown経由で導入予定)
  • AdSense(記事が溜まってから)
  • Cookie同意まわり(EEA向けに出すなら)

とりあえず、無料でメンテフリーな自分のブログが自分のドメインで公開できた。満足。次は中身を埋めていく番。