TechBlog一覧へ
AWS 2026年4月3日

PR単位のプレビュー環境をCloudFront + S3で低コストに構築した話

XECIN CloudFrontS3GitHub ActionsCI/CD

きっかけは「PRのたびにローカルで確認するの、もう無理」だった

私たちのチームでは、コーポレートサイトの更新をPRベースで運用しています。コンテンツの追加やデザインの微調整が入るたびに、レビュアーがローカルに git pull して npm run dev する——正直なところ、これがかなりの負担になっていました。

「URLをクリックするだけで確認できる環境がほしい」。チーム全員の共通認識でした。

なぜNetlify / Vercelを選ばなかったのか

最初に検討したのはやはりNetlifyやVercelです。PRプレビューは両サービスとも標準機能として備えていますし、セットアップも簡単です。

ただ、私たちの場合は事情が少し違いました。本番環境がすでにAWS(S3 + CloudFront)で動いていて、画像アセットや認証周りもAWSのエコシステムに乗っています。運用を考えると、プレビュー環境だけ別のプラットフォームに載せると、環境差異の管理が二重になる。CloudFrontのBehavior設定やキャッシュポリシーも本番と揃えたい、という判断がありました。

コスト面でも、S3の静的ホスティングならプレビュー環境のストレージ・転送量はほぼ誤差の範囲です。Vercel Proが月$20/メンバーに対して、S3のプレビュー用途は月$0.5にも満たない計算でした。

S3バケット1つでパス分離する設計

構成はシンプルで、プレビュー用のS3バケットを1つ用意し、PR番号をパスプレフィックスにする方式を採りました。

s3://preview-bucket/
  ├── pr-17/          ← PR #17 のビルド成果物
  │   ├── index.html
  │   ├── news/
  │   └── _astro/
  ├── pr-23/          ← PR #23 のビルド成果物
  └── prodBackup/     ← 本番バックアップ(別用途)

CloudFrontのオリジンをこのバケットに向けて、https://プレビューサイトURL/pr-17/ でアクセスできるようにしています。

これ、設計としてはシンプルなんですが、最初に1つ見落としがありました。

Astroの base パスで盛大にハマった話

Astroで静的サイトを生成する場合、サブディレクトリにデプロイするには base の設定が必要です。私たちは最初、これを見落としていました。

ビルドしてS3にアップロードして、プレビューURLを開くと——CSSもJSも全部404。Viteがバンドルしたアセットのパスが /pr-17/_astro/... ではなく /_astro/... になっていたんです。

修正はGitHub Actionsのビルドステップで環境変数を渡す形に落ち着きました。

- name: Build
  run: npm run build
  env:
    PUBLIC_SITE_URL: https://プレビューサイトURL
    ASTRO_BASE_PATH: /pr-${{ github.event.number }}

これでViteが生成するアセットパスにもプレフィックスが付くようになります。ただし public/ ディレクトリの画像は自動変換されないので、コンポーネント側で import.meta.env.BASE_URL を使って明示的にパスを組み立てる必要がありました。ここは今でもたまに新しいメンバーが引っかかるポイントです。

Cache-Controlの使い分け

本番デプロイでは、HTMLとアセットでキャッシュ戦略を分けています。

# HTML → キャッシュなし(常に最新を返す)
aws s3 sync ./dist s3://$PROD_BUCKET/ \
  --include "*.html" \
  --cache-control "no-cache, no-store, must-revalidate"

# アセット → 長期キャッシュ(ファイル名にハッシュが入る)
aws s3 sync ./dist s3://$PROD_BUCKET/ \
  --exclude "*.html" \
  --cache-control "public, max-age=31536000, immutable"

プレビュー環境では、もう少し割り切って全ファイル no-cache にしています。PRのプッシュのたびにビルドが走るので、キャッシュが残っていると古い状態が見えてしまう。レビュアーが「直ってないじゃん」と混乱するほうが怖いので、ここは潔く全部キャッシュなしにしました。

改善の余地はあって、アセットだけは immutable にしても問題ないはずです。Viteのハッシュ付きファイル名が変わるので理論的にはキャッシュが効いても安全なんですが、まだ切り替えていません。

GitHub Actionsからのプレビューコメント自動投稿

PRが作成・更新されるたびに、GitHub Actionsがビルド→S3デプロイ→PRコメントの順で動きます。

- name: Comment preview URL
  uses: actions/github-script@v7
  with:
    script: |
      const prNumber = context.issue.number;
      github.rest.issues.createComment({
        issue_number: prNumber,
        owner: context.repo.owner,
        repo: context.repo.repo,
        body: [
          '## プレビュー環境',
          '',
          `https://プレビューサイトURL/pr-${prNumber}/`,
          '',
          'ビルド: 成功',
        ].join('\n')
      });

レビュアーはPRのコメント欄に貼られたURLをクリックするだけで確認できます。「ローカルでビルドして」と頼む必要がなくなったのは、地味ですが大きな改善でした。

振り返って

プレビュー環境のおかげでレビューのスピードが明らかに上がりました。特にデザイン調整やコンテンツ追加のPRでは、実際の見た目を確認できることの安心感が大きいです。

コストは月$0.3〜0.5程度で、S3のライフサイクルルールでマージ済みPRのファイルを自動削除する設定も入れています。

今後は、Lighthouse CIをプレビューデプロイ後に走らせてパフォーマンススコアもPRコメントに載せたいと考えています。レビューの質をもう一段上げられるはずです。