blog

Cloudflare Pagesで9サイトを無料で回して踏んだ罠

このサイトに載せているツールは、どれもブラウザの中だけで動きます。PDF の検査も動画の書き出しも音の合成も手元の端末でやるので、サーバーは静的ファイルを配るだけで済みます。そこで、つやっと本体を含む 9 つのサイトを、すべて Cloudflare Pages の無料枠に置いています。住所は nyuko-checker.tsuyatt.com や noise-download.tsuyatt.com のような、tsuyatt.com のサブドメインです。Aim Training だけはゲーム本体を itch.io に置いていて、Pages ではプライバシーポリシーと利用規約のページを配っています。

静的サイトのホスティングは簡単そうに見えます。実際、ファイルを上げれば公開まではすぐでした。ところが、公開後の細部(転送、キャッシュ、ヘッダー、404)で、同じ罠を別々のプロジェクトで何度も踏みました。この記事では、9 つのプロジェクトで落ち着いた設定と、そこに至るまでのつまずきを書きます。

実装の多くは Claude Code と一緒に書いています。以下に出てくる「最初に書かれたコード」の中には、Claude Code が書いて、あとで私が気づいて直したものも含まれます。

全体の構成

9 つのプロジェクトの作りは、ほぼ同じです。

ファイル役割
functions/_middleware.js<project>.pages.dev から独自ドメインへの 301
public/_routes.json静的ファイルを Function の対象から外す
public/_headersCSP などのセキュリティヘッダーと、キャッシュの指定
public/404.html存在しない URL に 404 を返すため

デプロイは GitHub 連携を使わず、手元から wrangler で直接上げています(Cloudflare の用語では Direct Upload)。

npm run build && wrangler pages deploy dist --project-name rollpoly

GitHub Actions はよいサービスですが、処理時間が長くなったり容量が重くなったりすると落ちることがありました。しかも push のたびに毎回走って通知が来るので、AI と一緒に開発する速さだと邪魔になることが多く、使わなくなりました。本番に出す前に確かめたいときは、--branch=<名前> を付けてプレビューデプロイにします。<hash>.<project>.pages.dev という別の住所に出るので、本番を汚さずにエッジでの挙動を見られます。

wrangler.toml にプロジェクト名と出力先を書いておくと、--project-name を毎回付けずに済みます(10秒だけ!の例)。

# Cloudflare Pages(Direct Upload モード)
#
# GitHub 連携は使わず、ローカルから `npm run deploy` で直接アップロードする。
# name と pages_build_output_dir を書いておけば、deploy 時に --project-name が要らない。
#
# 本番ドメイン 10secgames.tsuyatt.com は Wrangler からは設定できないため、
# 初回のみ Cloudflare ダッシュボードで割り当てる(README を参照)。

name = "10secgames"
pages_build_output_dir = "dist"
compatibility_date = "2026-08-10"

コメントにあるとおり、独自ドメインの割り当ては wrangler からはできず、ダッシュボードでの作業になります。tsuyatt.com のゾーンが同じアカウントにあれば、CNAME は自動で作られます。

pages.dev は独自ドメインを付けても生き続ける

Pages のプロジェクトを作ると、まず <project>.pages.dev という住所が割り当てられます。独自ドメインを足しても、この pages.dev の住所は消えません。放っておくと、同じ内容が 2 つのホストで 200 を返し、検索エンジンからは重複したページに見えます。<link rel="canonical"> で正規の URL を示すこともできますが、これは検索エンジンへのヒントにとどまるので、確実なのは 301 で寄せることです。

301 は Function でしか書けない

ホスト名を見て転送する方法は、Cloudflare の中に 3 つありそうに見えます。そのうち使えるのは 1 つだけでした。

  • ゾーンの Redirect Rules:自分のゾーン(tsuyatt.com)へのリクエストにしか効きません。pages.dev は Cloudflare が持つ共有のドメインで、自分のゾーンではありません
  • _redirects ファイル:from に書けるのはパスだけで、ホスト名を書けません。公式ドキュメントの対応表でも、ドメイン単位の転送は非対応になっています1
  • Pages Functions:リクエストの URL を見て自由にレスポンスを返せるので、ホスト名で分岐できます

結論として、ホスト単位の転送は functions/_middleware.js に書くことになります。最初にこれを入れたのは入稿前チェッカー(2026 年 7 月 22 日)で、いまは 9 つすべてに同じ判定が入っています。

// Cloudflare Pages Function。旧URL(nyuko-checker.pages.dev)へのアクセスを
// 独自ドメインへ 301 で集約する。_redirects はホスト名でのマッチができず、
// Redirect Rules は自ゾーン(tsuyatt.com)にしか効かないため、ここで処理する。
//
// URL を変える場合は apps/web/index.html の OGP/canonical も合わせて直すこと。
const LEGACY_HOST = 'nyuko-checker.pages.dev';
const CANONICAL_ORIGIN = 'https://nyuko-checker.tsuyatt.com';

export const onRequest = ({ request, next }) => {
  const url = new URL(request.url);
  // ホスト完全一致。プレビューデプロイ(<hash>.nyuko-checker.pages.dev)は
  // 検証用なので転送しない。
  if (url.hostname === LEGACY_HOST) {
    return Response.redirect(CANONICAL_ORIGIN + url.pathname + url.search, 301);
  }
  return next();
};

同じ結論に 3 回たどり着いた

この結論は、一度出したあとも、プロジェクトが変わるたびに忘れていました。

2 つ目のチャットシアター(7 月 24 日)では、Claude Code が「*.pages.dev にはサーバー側のリダイレクトを設定できない」と判断し、location.replace で飛ばす JavaScript の転送を入れました。これでは検索エンジンへの 301 になりません。私が「HTTP レベルの 301 で飛ばしたいです。入稿チェッカーではそうしてるはず」と指摘して、Function に差し替えました。

3 つ目のノイズダウンロード(8 月 3 日)では、Netlify の書き方で _redirects にホスト名入りの行が書かれていました。

https://noise-download.pages.dev/* https://noise-download.tsuyatt.com/:splat 301

Cloudflare はこの行をエラーにせず、黙って無視します。私が「リダイレクトの設定いっつも忘れちゃうんだよね」「ファイルで書いておけばいいのか、CloudFlareの Web GUIで設定したのか忘れちゃう」と書いたのをきっかけに調べ直し、ここでも Function に直しました。

その後、9 月 8 日につやっと本体へ同じ転送を入れたときに、手順を Claude Code のスキル(どのプロジェクトのチャットからでも呼べる手順書)にまとめました。それ以後に作った Rollpoly、10秒だけ!、Rollshade にも、同じホスト判定の 301 が入っています。

ホスト名は完全一致で比べる

url.hostname.endsWith('.pages.dev') と書きたくなるところですが、これは壊れます。プレビューデプロイの住所は <hash>.10secgames.pages.dev なので、endsWith だとプレビューまで本番へ飛ばされ、出す前の確認ができなくなります。10秒だけ!の middleware には、そのことをコメントで残しています。

  // **ホストは完全一致で見ること。** `endsWith('.pages.dev')` にすると
  // プレビューデプロイ(`<ハッシュ>.10secgames.pages.dev`)まで本番へ飛び、
  // 実機確認ができなくなる。
  if (url.hostname === LEGACY_HOST) {

独自ドメインを先に、301 を後に

順番にも注意が要ります。独自ドメインの証明書が出る前に 301 を入れると、転送先が開けない時間ができます。入稿前チェッカーで移行したときの手順も、https://nyuko-checker.tsuyatt.com が開くことを先に確かめ、そのあとで middleware をデプロイする順番にしていました。

デプロイする場所で Function が消える

wrangler は、コマンドを実行したディレクトリの直下にある functions/ を拾います。出力先の dist/ や public/ の中に置いても拾われず、別の場所から wrangler pages deploy を叩くと、Function を含まないデプロイが黙って成功します。pages.dev はその瞬間から 200 に戻りますが、エラーはどこにも出ません。各プロジェクトの README には「リポジトリのルートで実行すること」(ショート動画メーカーは app/ の中、Aim Training の規約サイトは site/ の中)と書いてあります。実際に踏んだ記録はありませんが、気づきにくさを考えて最初から書いておいた注意です。

Function を置くと、画像 1 枚ごとに無料枠が減る

Function を置くと、既定では すべてのリクエストが Function を起動します2。Pages の静的ファイルへのリクエストは無料かつ無制限ですが、Function の起動は Workers のリクエストとして数えられ、無料プランでは 1 日 10 万回までです3。301 のためだけに置いた Function が、JS や画像のリクエストのたびに走り、そのぶん枠を減らすことになります。

これを防ぐのが _routes.json です。exclude に書いたパスは Function を通らず、静的ファイルとして直接返ります。つやっと本体はいちばん単純な形です。

{
  "version": 1,
  "include": ["/*"],
  "exclude": ["/assets/*"]
}

ショート動画メーカーは効果音を 26 本持っていて、1 回使うだけで数十のリクエストが出ます。そのため、ファイルを置いている場所をひととおり除外しています。

{
  "version": 1,
  "include": ["/*"],
  "exclude": [
    "/assets/*",
    "/sounds/*",
    "/samples/*",
    "/favicon.svg",
    "/apple-touch-icon.png",
    "/ogp.png",
    "/robots.txt",
    "/sitemap.xml"
  ]
}

exclude は include より常に優先されます。ルールは include と exclude を合わせて 100 個までです2。

除外には副作用が 1 つあります。除外したパスは Function を通らないので、pages.dev の住所でも 301 にならず、200 のまま返ります。たとえば https://tsuyatt.pages.dev/assets/site.js は、いまも 200 です。画像や JS が 2 つのホストで取れても検索の評価には響かないので、ここは割り切っています。

_headers は Function を通ると効かないのか

_headers は、パスごとにレスポンスヘッダーを足すための設定ファイルです。公式ドキュメントには、Pages Functions が生成したレスポンスには _headers が適用されない、と書かれています4。

これを「Function を置いたプロジェクトでは _headers が効かない」と読んだ時期がありました。Rollpoly では、他サイトの iframe に埋め込まれないための frame-ancestors を、そのために middleware の中で付けています。

export const onRequest = async ({ request, next }) => {
  const url = new URL(request.url);
  // ホスト完全一致。プレビューデプロイ(<hash>.rollpoly.pages.dev)は検証用なので転送しない。
  if (url.hostname === LEGACY_HOST) {
    return Response.redirect(CANONICAL_ORIGIN + url.pathname + url.search, 301);
  }
  const origin = await next();
  const response = new Response(origin.body, origin);
  response.headers.set('Content-Security-Policy', "frame-ancestors 'self'");
  return response;
};

ところが、本番の tsuyatt.com を調べると、話は違いました。トップページへのリクエストは middleware を通っています(_routes.json で除外していないうえ、pages.dev では 301 が返るので、Function が動いているのは確かです)。それでも、レスポンスには _headers に書いた CSP がそのまま付いていました。middleware が next() で静的ファイルに処理を渡した場合、そのレスポンスには _headers が効きます。効かないのは、Function の中で new Response(...) から作ったレスポンスのほうです。

Rollpoly の書き方でも結果として正しいヘッダーは付くので、害はありません。ただ、ヘッダーを足すためだけに Function で包み直す必要はありませんでした。

_headers と _routes.json は public/ に置きますが、Pages が設定として読むだけで、配信はされません(https://tsuyatt.com/_headers は 404 です)。そのため、この 2 つには理由をコメントで書き込んでいます。

404.html が無いと、存在しないページもすべて 200 になる

Pages は、出力先のトップに 404.html が無いと、そのプロジェクトを SPA とみなします。存在しない URL へのリクエストにも、すべて index.html を 200 で返します5。SPA ならこれで動きますが、検索エンジンからは中身の同じページが無数にあるように見えます(ソフト 404 と呼ばれる状態です)。

この挙動は、検索の評価以外でも問題を隠しました。Rollpoly では、README から参照していた /demo.gif をデプロイし忘れていました。ブラウザで開くと GIF ではなくアプリの画面が出るので、ファイルが無いことにしばらく気づきませんでした。404.html を足したコミットには、そのいきさつが書いてあります。

Add a 404 page so unmatched paths stop returning 200

Without a top-level 404.html, Pages assumes the project is a single-page
application and answers every unmatched path with index.html and a 200. That
is how a /demo.gif that was never deployed still rendered the app instead of
failing: the soft 404 hid it.

10秒だけ!は、ゲームごとのページを持つ SPA でした。こちらは SPA の扱いをやめ、ビルド時にすべてのページを HTML ファイルとして書き出す形(プリレンダー)に変えています。こうすると、存在しない URL には正しく 404 が返ります。代わりに、ルーターが知っているページを書き出し忘れると、本番でそのページだけ 404 になります。これはテストで、ルーターが解釈できるルートと生成するファイルが一致することを確かめて防いでいます。

describe('ルートと生成ファイルの対応', () => {
  // ここがずれると本番でそのページだけ 404 になる。**この作業でいちばん怖いところ。**
  it('ルーターが解釈できるルートは、すべて静的ファイルとして生成される', () => {
    for (const route of allRoutes()) {
      expect(parsePath(pathFor(route))).toEqual(route);
      expect(outputPathFor(route)).toMatch(/\.html$/);
    }
  });

SPA のために _redirects に /* /index.html 200 と書いている例もよく見かけます。入稿前チェッカーでもこの 1 行を書いていましたが、デプロイのログを見ると wrangler が「Infinite loop detected in this rule and has been ignored」と警告し、無視していました。それでも SPA が動いていたのは、404.html が無いときの既定の挙動のおかげです。

no-cache と書いたのに max-age=14400 が付く

いちばん原因が分かりにくかったのは、ノイズダウンロードに英語版を足したときの件です(8 月 3 日)。デプロイ後、私はこう書いています。

ローカルで確認すると問題なく英語になってるんだけど、本番では中途半端に英語になってました。sw.jsのCACHEとか関係ある?

画面の文言(「Create MP3 file」など)は英語なのに、ノイズの名前だけが日本語のままでした。本番の HTML も JS も、手元のビルドとハッシュまで一致しています。配信されているファイルは正しいので、疑うべきは閲覧している側のキャッシュでした。

本番のレスポンスヘッダーを見ると、_headers で /* に Cache-Control: no-cache を指定したのに、JS には次のヘッダーが付いていました。

cache-control: max-age=14400
cf-cache-status: MISS

max-age=14400 は 4 時間です。同じブロックに書いた CSP や nosniff は効いているのに、Cache-Control だけが置き換わっていました。HTML のほうは no-cache がそのまま届きます。こうして、新しい HTML と、4 時間前の日本語ベタ書き時代の catalog.js(ノイズ名の定義)が組み合わさりました。英語の文言を持つ i18n.js は新しく足したファイルでキャッシュが無かったので、そこだけ英語になったわけです。

Service Worker の版数を上げても直りませんでした。cache.addAll() は通常の HTTP キャッシュを経由してファイルを取りに行くので、新しい版のキャッシュに古い JS が入ってしまいます。直したのは、Service Worker の install でネットワークから必ず取り直す部分です。

self.addEventListener('install', e => {
  e.waitUntil(
    caches.open(CACHE).then(cache => Promise.all(SHELL.map(url =>
      fetch(new Request(url, { cache: 'reload' })).then(res => {
        if (!res.ok) throw new Error(`${url} が ${res.status}`);
        return cache.put(url, res);
      })
    ))).then(() => self.skipWaiting())
  );
});

あわせて、キャッシュの版数を手で上げるのをやめ、CSS と JS と HTML の内容のハッシュから自動で決めるようにしました。ページ本体はネットワークを優先して取りに行きます。すでに古いファイルを持っている訪問者は、1 回の再読み込みで新しい版に移ります。

当時は、この上書きを「Cloudflare Pages が静的アセットの Cache-Control を書き換える」と理解して、リポジトリにもそう書きました。ただ、あとで調べ直すと、別の説明のほうが合っていそうです。

  • max-age=14400 になるのは、cf-cache-status が MISS のファイル(Cloudflare が拡張子からキャッシュ対象とみなす .js や .css)だけです。DYNAMIC の HTML には no-cache が通ります
  • つやっと本体の /assets/site.js には、_headers に書いた max-age=86400(1 日)がそのまま付きます。4 時間より長い指定は書き換えられていません
  • Cloudflare のゾーンには Browser Cache TTL という設定があり、既定は 4 時間です。オリジンの Cache-Control がこれより短いと、Cloudflare が上書きします6

つまり、Pages の仕様というより、tsuyatt.com のゾーン設定がキャッシュ対象のファイルに効いている可能性が高いと考えています。ダッシュボードの設定値はまだ確かめていないので、ここは推測です。どちらの説明でも、wrangler pages dev では再現せず、本番の独自ドメインでだけ起きる点は同じです。対策も同じで、ファイル名が変わらないアセットを短くキャッシュさせたいなら、Service Worker やクエリ文字列(つやっと本体の site.js?v=<ハッシュ>)で、キャッシュの外から古さを断ち切ることになります。

ノイズダウンロードの英語版の作り方は、ノイズダウンロードの記事に書いています。

CSP と、エッジで差し込まれる解析ビーコン

Pages の Web Analytics を有効にすると、Cloudflare がエッジで HTML に beacon.min.js を差し込みます。このスクリプトはリポジトリのどこにもありません。

つやっと本体は default-src 'none' から始める厳しい CSP を付けていたので、差し込まれたビーコンはブロックされ、計測が 1 件も取れていませんでした(7 月 22 日に気づきました)。読み込み元と送信先を、それぞれ許可して直しています。

script-src 'self' https://static.cloudflareinsights.com; connect-src 'self' https://cloudflareinsights.com

送信は通常、同じオリジンの /cdn-cgi/rum に飛び、成功すると 204 が返ります。ビーコンの設定によっては cloudflareinsights.com へ直接送る分岐があるので、両方を許可しています。Web Analytics を有効にした直後は、まだビーコンは入りません。差し込まれるのは、有効にした後の次のデプロイからでした(Rollpoly で確認しました)。

当時は「ビーコンはブラウザからのリクエストにだけ差し込まれ、素の curl では見えない」とメモしていました。2026 年 10 月にあらためて curl で取ると、User-Agent を付けなくてもビーコンが見えます。差し込む条件が変わったのか、私の観察が足りなかったのかは分かっていません。

AdSense のために CSP を作り替えて、戻した

つやっと本体では、2026 年 9 月に Google AdSense を申請しました。所有者確認のタグを入れると、本番のコンソールに googleads.g.doubleclick.net の iframe が default-src 'none' に違反している、というエラーが出ました。ドメインを 1 つずつ許可していく方法は、AdSense が公式にサポートしておらず、予告なく壊れることがあると明記されています。サポートされているのは、nonce と 'strict-dynamic' を使う CSP だけです。

nonce はリクエストごとに変える必要があるので、_headers には書けません。middleware で HTML を書き換え、<script> に nonce を付けるようにしました(当時のコードの一部です)。

export const onRequest = async ({ request, next }) => {
  // ...
  const res = await next();
  // sitemap.xml や robots.txt にまで HTMLRewriter を通す意味はない。
  if (!/^text\/html/i.test(res.headers.get('content-type') || '')) return res;
  const nonce = crypto.randomUUID().replaceAll('-', '');
  // ...
  const out = new HTMLRewriter()
    .on('script[src]', { element: (el) => el.setAttribute('nonce', nonce) })
    .transform(res);
  // _headers 側に CSP が残っていても set で上書きする(二重に付くと
  // ブラウザは両方を適用するので、緩いほうだけ残すつもりが壊れる)。
  out.headers.set('Content-Security-Policy', csp(nonce));
  return out;
};

気がかりだったのは、先ほどの解析ビーコンです。'strict-dynamic' を使うとドメインの許可リストが無効になるので、エッジで差し込まれるビーコンに nonce が付かなければ、計測が止まります。プレビューデプロイで確かめると、ビーコンの差し込みは next() より手前で起きていて、HTMLRewriter から見えていました。そのため、ビーコンにも nonce が付きました。

ローカルでだけ起きる問題も 1 つありました。wrangler pages dev は HTML に ETag を付けるので、再読み込みすると 304 が返ります。304 には Content-Type が無いので、middleware の HTML 判定を素通りし、_headers 側の CSP が付きます。ブラウザはキャッシュしていたヘッダーをそれで上書きするので、2 回目以降の読み込みでだけ広告がブロックされました。本番では HTMLRewriter を通ると ETag が消えるので、304 にはなりません。ここまでで見たように、本番でしか起きないことも、ローカルでしか起きないこともあるので、エッジの挙動はプレビューデプロイで確かめるのがいちばん確実でした。

審査は「有用性の低いコンテンツ」で通りませんでした。2026 年 10 月に広告と nonce の CSP を外し、_headers だけの形に戻しています。

プロジェクトを消すと戻らないもの

Rollpoly は 9 月に一度、公開をやめました。そのとき Pages のプロジェクトごと削除していたので、3 日後に再開しようとしたデプロイは「The Pages project "rollpoly" does not exist」で失敗しました。wrangler pages project create で作り直すと、rollpoly.pages.dev の名前は取り戻せました。ただし、独自ドメインの割り当てと Web Analytics の設定は戻りません。その間、rollpoly.pages.dev は存在しない rollpoly.tsuyatt.com へ 301 し続けていました。

一時的に公開をやめたいだけなら、プロジェクトは消さずに、独自ドメインの割り当てを外すほうが戻しやすいはずです。

新しく作るときに揃えるもの

9 つを作ってきて、新しいプロジェクトでは最初から次を揃えるようにしています。

  1. 独自ドメインをダッシュボードで割り当て、開けることを確かめる
  2. functions/_middleware.js に、ホスト完全一致の 301 を書く
  3. _routes.json で、HTML 以外のファイルを Function の対象から外す
  4. 404.html を置く(SPA なら、プリレンダーするかどうかも決める)
  5. _headers に CSP を書く。Web Analytics を使うなら、ビーコンの 2 つのドメインを許可する
  6. 名前が変わらないファイルのキャッシュは、4 時間より短くできないかもしれない前提で考える
  7. デプロイはリポジトリのルートから行い、初回はプレビューデプロイでヘッダーと 301 を確かめる

このうち 3 から 5 は、プロジェクトからプロジェクトへコピーする途中で抜け落ちることがありました(チャットシアターとノイズダウンロードには、いまも _routes.json がありません)。ファイルを足すたびに curl -sI で pages.dev と存在しない URL を叩いて確かめるのが、結局いちばん手堅いです。

各サービスの話は、それぞれの記事に書いています。

  1. Cloudflare Pages「Redirects」 https://developers.cloudflare.com/pages/configuration/redirects/ の非対応機能の表に、ドメイン単位の転送(workers.example.com/* ...)が挙がっています。 ↩
  2. Cloudflare Pages「Routing」 https://developers.cloudflare.com/pages/functions/routing/ 。Function を置くと既定で全リクエストが Function を起動すること、exclude が優先されること、ルールは合計 100 個までであることが書かれています。 ↩
  3. Cloudflare Pages「Pricing」 https://developers.cloudflare.com/pages/functions/pricing/ 。2026 年 10 月 7 日に確認しました。 ↩
  4. Cloudflare Pages「Headers」 https://developers.cloudflare.com/pages/configuration/headers/ ↩
  5. Cloudflare Pages「Serving Pages」 https://developers.cloudflare.com/pages/configuration/serving-pages/ ↩
  6. Cloudflare「Browser Cache TTL」 https://developers.cloudflare.com/cache/how-to/edge-browser-cache-ttl/ 。「Respect Existing Headers」に切り替えると上書きしなくなります。 ↩

← 記事一覧へ