このブログのホスティング費用は0円です。
払っているのはドメイン代だけで、それ以外に固定費はありません。記事は現在29本あります。
WordPressをやめた話では「なぜ移行したか」を書きました。この記事では、移行してから実際どうなっているかを数字で書きます。
構成
やっていることは単純です。GitHubの main にマージすると、Cloudflareが自動でビルドして配信します。その間、私の作業はありません。
| 役割 | 使っているもの | 費用 |
|---|---|---|
| サイト生成 | Astro | 0円 |
| ソース管理 | GitHub | 0円 |
| 配信 | Cloudflare | 0円 |
| ドメイン | Cloudflare Registrar | 年 $11.84 程度 |
ドメインだけが有料です。金額の根拠と他社との比較はドメイン取得サービスの比較記事にまとめています。
「Pages」のつもりが、Workersだった
これは書いていて気づきました。
移行時のメモにも、前に書いた記事にも「Cloudflare Pages」と書いていました。ところが実際のデプロイ先を確認すると、workers.dev のドメインが割り当てられていました。
つまりこのブログは、Pagesではなく Cloudflare Workers の静的アセット配信で動いています。設定ファイルもこれだけです。
{
"name": "blog-tech",
"compatibility_date": "2026-08-14",
"assets": {
"directory": "./dist"
}
}
assets.directory で出力先を指しているだけで、サーバーのコードは1行も書いていません。
CloudflareはPagesからWorkersへの移行ガイドを公式に用意しており、静的サイトをWorkersで配信することを想定した作りになっています。Pagesの終了について公式が何か言っているわけではありませんが、これから始めるなら、どちらで動いているのかは確認しておいたほうがいいと思います。無料枠の条件が変わってきます。
無料枠はどこまでか
Cloudflareの公式ドキュメントに書かれている、無料プランの上限がこちらです。
| 項目 | 無料プランの上限 |
|---|---|
| 1デプロイあたりのファイル数 | 20,000 |
| 1ファイルの最大サイズ | 25 MiB |
そして課金について、公式にこう明記されています。
Requests to static assets are free and unlimited.
静的なファイルへのアクセスは、無料で無制限ということです。アクセスが増えても課金されません。課金が発生するのは、サーバー側でコードを動かした場合です。このブログは静的ファイルを置いているだけなので、そこに該当しません。
手元の現在地
では、29記事を載せた現状はどれくらいなのか。ビルド結果を数えました。
| 項目 | 実測 | 上限に対して |
|---|---|---|
| ファイル数 | 228 | 20,000 の 約1% |
| 最大ファイルサイズ | 0.6MB | 25 MiB の 約2% |
| 合計サイズ | 約32MB | — |
内訳は、HTMLが38・画像が182・その他8でした。画像の大半はAstroが自動生成したWebPです。
記事1本あたり、おおよそ8ファイル増える計算です。単純に割れば、上限の20,000ファイルまでには2,000本以上書ける余地があります。
💡 個人ブログの規模で無料枠に当たることは、まず考えなくていいと思います。当たるとすればファイル数で、画像を大量に載せる構成のときです。
そもそも、何が置かれているのか
「静的サイト」という言葉が分かりにくいので、実際に配信されているものの中身を確認しましょう。
| 種類 | 数 |
|---|---|
| HTML | 40 |
| 画像(webp / jpg / png / svg) | 184 |
| CSS | 1 |
| サイトマップ等(xml / txt) | 5 |
ここに .ts も .astro も、JavaScriptのファイルは1つもありません。
といっても、それらを使っていないわけではありません。資材管理をしているGitHubリポジトリには .astro が13ファイル、.ts が2ファイルあります。ただしHTMLを作り終えた時点で役目が終わるので、配信されるものには残らない、というだけです。何をしているファイルなのかは後述します。
図にすると、こういう関係です。
⚠️ .astro と .ts は、下段(配信されるもの)に出てきません
上段が手元にあるもの、下段がCloudflareに置かれるものです。
色を付けた .astro と .ts が、いわゆるプログラムにあたる部分です。この2つはビルドの中で使われるだけで、下段には現れません。
中身を見ておきます。まず .astro は、上下2段構造になっています。
---
// 上段:HTMLを作る前の準備。ここはTypeScriptで書く
const { post } = Astro.props;
const dateFmt = new Intl.DateTimeFormat('ja-JP', { dateStyle: 'long' });
---
<!-- 下段:出力するHTMLの形。{ } の中に上段の値が入る -->
<time>{dateFmt.format(post.data.pubDate)}</time>
<h1>{post.data.title}</h1>
--- で挟まれた上段が準備、その下がHTMLの形です。{ } で囲んだ部分に、上段で用意した値が差し込まれます。これで 2026-08-15 が 2026年8月15日 になります。
もう一方の .ts は、設定と定義を置く場所です。
// カテゴリの識別子と、画面に出す日本語の対応表
export const CATEGORIES = {
gadget: 'ガジェット',
travel: '旅行',
cafe: 'カフェ',
coffee: 'コーヒー',
tech: '技術',
} as const;
記事には category: gadget と書いておき、表示するときに「ガジェット」へ変換しています。日本語表記を変えたくなったら、この1か所を直せば全ページに反映されます。
ここから先は、それぞれが何をしているのかを順に見ていきます。
つまりCloudflare側ではプログラムが動いていません。読者が記事のURLにアクセスすると、あらかじめ用意してあるHTMLをそのまま返しているだけです。データベースへの問い合わせも、ページの組み立ても起きません。
リクエストのたびに計算しないので、アクセスが増えても負荷が増えません。これが「静的アセットへのリクエストは無料・無制限」の理由です。
記事1本が、どうHTMLになるか
では、書いているMarkdownはどこへ行くのか。実際の記事で追ってみます。
コーヒー豆の保存方法という記事は、こう変わっていました。
| サイズ | |
|---|---|
| 元のMarkdown | 7,145バイト |
| 出力されたHTML | 16,004バイト |
倍以上に増えています。 増えたぶんが、自分では書いていない部分です。
frontmatter は「指定書き」になる
記事の先頭には、こう書いています。
---
title: 'コーヒー豆の保存方法|常温・冷蔵・冷凍どれが正解?...'
pubDate: 2026-08-15
category: coffee
---
これが出力ではこうなります。
<div class="post-meta">
<time datetime="2026-08-15T00:00:00.000Z">2026年8月15日</time>
<a class="category-badge" href="/categories/coffee/">コーヒー</a>
</div>
<h1>コーヒー豆の保存方法|常温・冷蔵・冷凍どれが正解?...</h1>
pubDate: 2026-08-15 と書いただけで、「2026年8月15日」という日本語表記と、機械が読む datetime 属性の両方が出ています。category: coffee も、日本語のラベルとカテゴリページへのリンクに変換されています。
本文は素直に変換される
こちらは分かりやすいです。
| Markdown | HTML |
|---|---|
**最後の方は香りが弱くなっている** |
<strong>最後の方は香りが弱くなっている</strong> |
## コーヒー豆が劣化する4つの原因 |
<h2 id="コーヒー豆が劣化する4つの原因">…</h2> |
見出しに id が自動で付くのがポイントです。これがあるので、目次から各見出しへ飛べます。目次はMarkdownに一行も書いていません。
HTMLを組み立てている3種類のファイル
その「自分では書いていない部分」を作っているのが、src/ の中にある .astro ファイルです。Astroでは置き場所によって役割と呼び名が決まっています。
| 種類 | 置き場所 | 役割 | このブログでの数 |
|---|---|---|---|
| ページ | src/pages/ |
URLに対応する。ここに置いたファイルがそのままページになる | 7 |
| レイアウト | src/layouts/ |
全ページ共通の外枠。ヘッダー・フッター・メタタグ | 1 |
| コンポーネント | src/components/ |
部品。目次・シェアボタン・広告タグなど | 6 |
記事のHTMLは、この3つが組み合わさってできています。順に見ていきます。
ページ:URLを作る
記事ページを作っているのは src/pages/articles/[...id].astro です。51行しかありません。
ファイル名の [...id] が特徴で、これは動的ルートと呼ばれる書き方です。about.astro が /about/ という1つのURLにしかならないのに対し、この1ファイルが31本ぶんのURLを作ります。
前半:どのURLを作るかを決める
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.PROD ? !data.draft : true
);
return posts.map((post) => ({
params: { id: post.id },
props: { post },
}));
やっていることは2つです。
- 記事を全部集めてくる
- 1本につき1ページ作る
import.meta.env.PROD ? !data.draft : true の行は、本番ビルドのときだけ下書きを除外しています。手元では下書きも見えるので、公開前の確認ができます。
後半:HTMLの形
<Base title={post.data.title}>
<article>
<header class="article-header">
<time>{dateFmt.format(post.data.pubDate)}</time>
<a href={`/categories/${post.data.category}/`}>…</a>
<h1>{post.data.title}</h1>
</header>
<div class="article-body">
<TableOfContents headings={headings} />
<Content />
</div>
<ShareButtons title={post.data.title} />
</article>
</Base>
<Content /> がMarkdownの本文が入る場所です。
ここで前述の3種類が揃います。
- 全体を囲む
<Base>がレイアウト。ヘッダー・フッター・メタタグを付ける <TableOfContents>と<ShareButtons>がコンポーネント。目次とSNSボタンの部品- それらを並べているこのファイル自体がページ
コンポーネントは他のページからも呼べます。たとえば記事一覧のカード(PostList)は、トップページとカテゴリページの両方で使っています。
[...id].astro 1ファイルが、31本すべての記事ページを作る
ここが重要なところだと思います。
いま記事は31本ありますが、記事ページを作っているファイルは [...id].astro の1つだけです。
ビルドを実行すると、この1ファイルが記事の数だけ繰り返し使われます。
[...id].astro(1ファイル)
× 記事31本
↓
記事ページのHTML 31ページぶん
大事なのは、毎回すべての記事ぶんを作り直していることです。1本追加したときに差分だけ作るのではなく、31本ぶんを一から生成します。前述の「38ページで約5秒」は、この全生成にかかった時間です。
この作りの利点は、直す場所が1か所で済むことです。 ヘッダーの表示を変えたいとき、[...id].astro を1回直せば、次のビルドで31ページすべてに反映されます。これがなければ、出来上がったHTML 31個を手で開いて、同じ修正を31回繰り返すことになります。
プログラミング言語が使われているのは、この繰り返しを機械にやらせるためです。 Cloudflareで動かすためではありません。実際、生成が終わった時点で .astro も .ts も出番が終わり、配信されるのはHTMLだけです。
記事のルールを決めているTypeScript
.astro が13ファイルあるのに対して、.ts は2ファイルだけです。どちらも短いのですが、役割がはっきりしています。
| ファイル | 役割 |
|---|---|
content.config.ts |
記事に書ける項目のルールを決める |
consts.ts |
サイト名やGA4のIDなどの定数 |
面白いのは前者です。記事のfrontmatterに何を書いていいかを、ここで定義しています。
schema: ({ image }) =>
z.object({
title: z.string(),
pubDate: z.coerce.date(),
category: z.enum(['gadget', 'travel', 'cafe', 'coffee', 'tech']),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
読み方はそのままで、category には5つの値しか書けない、tags は文字列の配列、draft は真偽値で省略時は false、という宣言です。
書き間違えるとビルドが止まる
試しに、カテゴリを gadget ではなく gadgets(複数形)と書いてビルドしてみました。
[InvalidContentEntryDataError] data does not match collection schema.
category: Invalid option: expected one of
"gadget"|"travel"|"cafe"|"coffee"|"tech"
公開されずに、その場で止まります。
これがなければ、gadgets という存在しないカテゴリのページが静かに生成され、カテゴリ一覧から漏れた記事が誰にも気づかれないまま残ることになります。31本もあると、目視では気づけません。
型を書く価値はここにある
TypeScriptというと難しそうですが、このブログで実際にやっているのは**「書いていい値を並べておく」だけ**です。
そのかわり、間違いが本番に出る前に止まります。記事が増えるほど、この効果は大きくなります。
なお .astro ファイルも、上部の --- で囲まれた部分にはTypeScriptを書けます。前述の記事ページで日付を整形していた dateFmt などがそれにあたります。
結局、何を用意すればいいのか
Markdownだけでは足りません。最低限これだけ要ります。
| 必要なもの | 役割 |
|---|---|
| 記事のMarkdown | 中身 |
| ページ・レイアウト・コンポーネント | Markdownを何のHTMLにするか |
content.config.ts |
記事に書ける項目のルール |
| CSS | 見た目 |
package.json |
使うツールの一覧 |
astro.config.mjs |
サイトのURLなどの設定 |
wrangler.jsonc |
どのフォルダを配るか |
Markdownが材料で、HTMLが完成品です。Cloudflareが配れるのは完成品だけなので、材料を完成品に変える仕組みが別に要る、という関係になります。
では、その変換は誰がやっているのか。手元ではありません。
Cloudflare側で起きていること
GitHubの main にマージすると、Cloudflare側でこれが順に走ります。
- リポジトリを取得する — マージされた時点のソース一式
- Node.jsの環境を用意する — 空のマシンが割り当てられる
npm ciで道具を入れる —package.jsonに書いてある Astro などをダウンロードnpm run buildを実行する — Markdownとページファイルからdist/を生成dist/の中身を登録する —wrangler.jsoncが指しているフォルダ- 新しいバージョンとして切り替える — 世界中の配信拠点に反映される
ポイントは、3〜4が手元でやっているのと同じ作業だということです。
npm run build # 手元でもCloudflare側でも、走るのはこれ
だから手元でビルドが通れば、本番でも通ります。プレビューで確認しているのは「本番と同じ手順を先に踏んでいる」からで、サーバーに上げてみないと分からない、ということが起きません。
また、公開はバージョンの切り替えとして行われます。このリポジトリではPRブランチでも wrangler versions upload を使っており、マージ前にプレビュー用のURLで確認できるようにしてあります。
💡 ビルドコマンドやNode.jsのバージョンはCloudflareの管理画面側の設定です。リポジトリには書かれていないので、うまく動かないときはそちらも確認する必要があります。
書くことだけに集中したいなら、noteやはてなブログのようにページの作りもCSSもサーバーも向こう持ちのサービスが向いています。この構成は、それらを自前で持つ代わりに、月額をゼロにして表示速度と自由度を取っているという位置づけです。
ビルドとデプロイ
手元で、キャッシュを消した状態からビルドを3回計測しました。
- 38ページの生成に約5秒
画像の最適化を含めてこの時間です。Cloudflare側はここにリポジトリの取得と依存関係のインストールが加わるので、実際の反映まではもう少しかかります。PRをマージしてから本番で確認できるまで、体感で1分前後です。
記事を書く側の手順は、こうなります。
- 記事のMarkdownを書く
- PRを出す
- マージする
これだけで公開されます。サーバーに入る作業はありません。
独自ドメインとHTTPS
ドメインをCloudflare Registrarで取ったので、DNSの設定は実質不要でした。同じ事業者の中で完結するので、ネームサーバーを手で書き換える手間がありません。
HTTPS証明書も自動で用意されます。ここは何もしていません。
ひとつだけ、自分で直した設定
公開してしばらくして、Google Search Consoleから通知が来ました。調べると、http:// でアクセスしても https:// に転送されていませんでした。
HTTP/1.1 200 OK ← 転送されずに、そのまま表示されていた
httpとhttpsの両方でページが見える状態だったため、Googleが重複として扱っていました。Cloudflareの管理画面で 「Always Use HTTPS」をONにしたところ、301で転送されるようになりました。
HTTP/1.1 301 Moved Permanently
Location: https://sunny-engineering.com/...
証明書が自動で付くことと、httpからの転送が有効になっていることは別です。ここは見落としやすいので、公開したら一度確認しておくといいと思います。
結局いくらかかっているか
| 項目 | 年間 |
|---|---|
| ホスティング | 0円 |
| 独自ドメイン | $11.84 程度 |
| 合計 | ドメイン代のみ |
アクセス解析(GA4)、お問い合わせフォーム(Googleフォーム)、ソース管理(GitHub)も、いずれも無料の範囲で使っています。
S3で置くのと何が違うのか
静的サイトと聞いて、AWSのS3を思い浮かべる人もいると思います。ファイルを置いて公開する、という点では同じです。
違いは**「1つで完結するか、組み合わせが要るか」**でした。
S3単体ではHTTPSにできない
これが決定的な差です。AWS公式ドキュメントにこう明記されています。
Amazon S3 website endpoints do not support HTTPS or access points.
S3のウェブサイトエンドポイントは、HTTPSに対応していません。公式が案内している対処は2つで、CloudFrontを前に置くか、Amplify Hostingを使うかです。
いま独自ドメインのブログをhttpだけで公開するのは現実的ではないので、S3で作るなら実質「S3 + CloudFront」の組み合わせになります。
部品の数が違う
同じことをやるのに必要なものを並べると、こうなります。
| やりたいこと | S3で組む場合 | このブログの場合 |
|---|---|---|
| ファイルを置く | S3 | Cloudflare |
| CDNで配る・HTTPSにする | CloudFront を追加 | 最初から含まれる |
| 証明書を用意する | ACM で発行 | 自動 |
| 独自ドメインを向ける | Route 53 など | 同じ画面で完結(同社でドメイン取得のため) |
S3側が劣っているという話ではありません。 部品を分けているぶん、細かい制御はAWSのほうが効きます。アクセスログの扱いも、キャッシュの設定も、自分で決められます。
ただ個人ブログを1つ公開したいだけだと、その自由度を使う場面がほとんどありません。私の場合は「設定する箇所が少ないほうがいい」という理由で、いまの構成を選んでいます。
課金の考え方も違う
もうひとつの差が無料枠の当たり方です。
前述のとおり、Cloudflareは静的アセットへのリクエストが無料・無制限と明記されています。アクセスが増えても、この部分の請求は増えません。
一方でS3とCloudFrontは、保存量・リクエスト数・転送量にそれぞれ課金が発生する仕組みです(無料利用枠の範囲はありますが、条件は公式で確認してください)。個人ブログの規模ならどちらも少額に収まるはずですが、「気にしなくていい」と言い切れるかどうかは違ってきます。
💡 すでにAWSを業務で使っていて、他のリソースと同じ場所で管理したいなら、S3 + CloudFront は自然な選択だと思います。このブログの構成は「AWSより優れている」のではなく、「用意する部品が少ない」ほうを取ったというだけです。
この構成が向かない場合
正直に書くと、万能ではありません。
- サーバー側で動的な処理をしたい場合 — 静的なファイルを置くだけの構成では足りません。Workersでコードを動かすことになり、そこからは課金対象です
- 管理画面から記事を書きたい場合 — WordPressのような編集画面はありません。Markdownを書いてPRを出す運用に慣れが要ります
- 画像を大量に扱う場合 — 唯一、上限が見えるのがファイル数です
逆に、記事が数百本規模までの個人ブログで、書く手段としてMarkdownに抵抗がないなら、費用面で困ることはないと思います。
まとめ
- ホスティングは0円。払っているのはドメイン代だけ
- 配信されているのはHTMLと画像だけ。
.tsも.astroも1つも置かれていない - Markdownは材料、HTMLが完成品。変換する仕組みが別に要る
- ビルドはCloudflare側で走る。手元と同じ
npm run buildなので、手元で通れば本番でも通る - S3で同じことをやるならCloudFrontとの組み合わせが必要。S3のウェブサイトエンドポイントはHTTPS非対応(AWS公式明記)
- プログラミング言語はCloudflareで動かすためではなく、1つのページファイルから31記事ぶんのHTMLを作るために使っている
- TypeScriptは記事に書ける項目のルールを決めていて、書き間違えると公開前にビルドが止まる
- このブログはPagesではなく Workers の静的アセット配信で動いていた。設定は出力先を指す数行のみ
- 無料プランの上限は20,000ファイル / 1ファイル25 MiB
- 静的ファイルへのアクセスは無料・無制限(公式明記)。アクセス増で課金されない
- 29記事の現在地は228ファイルで、上限の約1%
- ビルドは38ページで約5秒。マージから公開まで体感1分前後
- 証明書の自動付与と、httpからの転送は別物。Always Use HTTPS は自分でONにする必要があった
移行を決めた理由そのものはWordPressをやめた話に、Astro側で踏んだ落とし穴はZennの記事に分けて書いています。