Classi でソフトウェアエンジニアをやっている koki です。
S3 + CloudFront でホスティングしている SPA (Single Page Application) で Pull Request ごとにプレビュー環境をデプロイする仕組みを作ってみたところ、かなり体験が良かったので紹介します。
前提
Classi で提供している学習トレーニング機能には、それを裏で支えるコンテンツ管理システム ( 以下、内部 CMS ) が存在しています。
この内部 CMS については以下の記事でも簡単に紹介されているので、こちらをご参照ください。
内部 CMS のフロントエンドは Angular を使用した SPA になっており、 Amazon S3 と Amazon Cloudfront を使用してホスティングされています。
抱えていた課題 / つくったもの
内部 CMS のフロントエンド開発をしている中で、他メンバーが作成した Pull Request をレビューする際、動作確認するためには「ローカルに branch を pull して」「ローカルで起動する」必要があり、レビューするまでの手間がかかるという課題がありました。
必要に応じてスクリーンショットや画面録画を Pull Request に貼るなどの運用もしていましたが、それでも限界があります。
「Pull Request ごとにサクッとプレビューできる環境が欲しいよね〜」ということで、仕組みを作りました。

この記事では Pull Request ごとに SPA のプレビュー環境を S3 + CloudFront へデプロイする仕組みや構成などについて解説していきます。
インフラ構成について
全体の構成としては以下のようなイメージで、一般的な S3 + CloudFront での静的ホスティング構成に CloudFront Function を加えただけの非常にシンプルな構成です。
この記事では、 https://pr-<PR番号>.classi.example のような URL から各 Pull Request ごとのプレビュー環境にアクセスできる状態を目指します。

💡 Route53 はあくまで DNS なので実際にリクエストが図のように Route53 を経由してそのまま CloudFront Distribution に送られるわけではありませんが、わかりやすくするために簡略化してます。
ひとつずつ順を追って説明していきます。
なお、今回は Terraform を用いてリソースを構築していきます。
S3 バケットを作成する
まずは S3 バケットを作成します。
この S3 バケットは SPA のビルド済み静的ファイルを配置するために使用します。
resource "aws_s3_bucket" "example" { bucket = "<任意のバケット名>" } # デフォルトの暗号化の設定 (SSE-S3) resource "aws_s3_bucket_server_side_encryption_configuration" "example" { bucket = aws_s3_bucket.example.id rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } } } # パブリックアクセスを全てブロック resource "aws_s3_bucket_public_access_block" "example" { bucket = aws_s3_bucket.example.id ignore_public_acls = true restrict_public_buckets = true block_public_acls = true block_public_policy = true }
この段階では特に変わった設定はしていません。
CloudFront Distribution を作成する
上述した S3 バケットをオリジンとする CloudFront Distribution を作成します。
また、 CloudFront Distribution 経由で S3 バケットにアクセスできるように OAC の作成および S3 バケットポリシーの設定もしています。
data "aws_cloudfront_origin_request_policy" "cors_s3_origin" { name = "Managed-CORS-S3Origin" } data "aws_cloudfront_cache_policy" "caching_disabled" { name = "Managed-CachingDisabled" } resource "aws_cloudfront_distribution" "example" { enabled = true # S3 バケットをオリジンに設定 origin { origin_id = aws_s3_bucket.example.id domain_name = aws_s3_bucket.example.bucket_regional_domain_name origin_access_control_id = aws_cloudfront_origin_access_control.example.id } # ここでは一旦デフォルトのドメインを使用しておく viewer_certificate { cloudfront_default_certificate = true } default_cache_behavior { target_origin_id = aws_s3_bucket.example.id viewer_protocol_policy = "redirect-to-https" cached_methods = ["GET", "HEAD"] allowed_methods = ["GET", "HEAD"] compress = true # gzip 圧縮を有効化 origin_request_policy_id = data.aws_cloudfront_origin_request_policy.cors_s3_origin.id # キャッシュポリシーは要件に応じて調整してください # この例ではキャッシュを無効にしています cache_policy_id = data.aws_cloudfront_cache_policy.caching_disabled.id } restrictions { geo_restriction { restriction_type = "none" } } } resource "aws_cloudfront_origin_access_control" "example" { name = "<任意のOAC名>" origin_access_control_origin_type = "s3" signing_behavior = "always" signing_protocol = "sigv4" }
# S3 バケットポリシー resource "aws_s3_bucket_policy" "example" { bucket = aws_s3_bucket.example.id policy = data.aws_iam_policy_document.allow_cloudfront_to_access_s3.json } # CloudFront Distribution 経由でのみ S3 バケットにアクセスできるようにする設定 data "aws_iam_policy_document" "allow_cloudfront_to_access_s3" { statement { sid = "AllowCloudFrontToAccessS3" actions = ["s3:GetObject"] resources = ["${aws_s3_bucket.example.arn}/*"] principals { type = "Service" identifiers = ["cloudfront.amazonaws.com"] } condition { test = "StringEquals" variable = "aws:SourceArn" values = [aws_cloudfront_distribution.example.arn] } } }
ドメインの設定や CloudFront Function の関連付けも行いますが、その辺りは後述します。
Route53 Hosted Zone とレコード / ACM 証明書を作成する
プレビュー環境に使用するドメイン名で Route53 Hosted Zone を作成します。
次のコードは preview.classi.example というドメイン名で作成する例です。
resource "aws_route53_zone" "example" { name = "preview.classi.example" }
また、この例では Apex ドメインの Hosted Zone ( 今回の例では classi.example ) に NS レコードを作成して権限移譲を行う必要があります。
詳細な手順については以下の記事がわかりやすいので、こちらをご参照ください。
💡 Apex ドメインの Hosted Zone をそのまま利用しても動作上は問題ありませんが、環境分離の観点からプレビュー環境用の Hosted Zone は分けて作成する方が管理上は望ましいでしょう。
作成した Route53 Hosted Zone 内に *.preview.classi.example という名前で A レコードを作成し、 CloudFront Distribution に関連付けます。
resource "aws_route53_record" "example" { # プレビュー環境用の Hosted Zone zone_id = aws_route53_zone.example.zone_id name = "*.preview.classi.example" type = "A" # 先ほど作成した CloudFront Distribution を関連付ける alias { name = aws_cloudfront_distribution.example.domain_name zone_id = aws_cloudfront_distribution.example.hosted_zone_id evaluate_target_health = false } }
ワイルドカード ( * ) を使用することで任意のサブドメインをマッチさせることができます。
Route53 における Hosted Zone およびレコードでのワイルドカードの使用について詳しくは以下の公式ドキュメントをご参照ください。
続いて、 *.preview.classi.example の ACM 証明書を作成します。
なお、この ACM 証明書は CloudFront Distribution に関連付けして使用するため、 us-east-1 リージョンに作成する必要があることに注意してください。 *1
provider "aws" { alias = "virginia" region = "us-east-1" } resource "aws_acm_certificate" "example" { # us-east-1 リージョンに作成する provider = aws.virginia domain_name = "*.preview.classi.example" subject_alternative_names = ["preview.classi.example"] validation_method = "DNS" lifecycle { create_before_destroy = true } } # DNS 検証用の Route53 レコード resource "aws_route53_record" "example_certificate_validation" { for_each = { for options in aws_acm_certificate.example.domain_validation_options : options.domain_name => { name = options.resource_record_name type = options.resource_record_type value = options.resource_record_value } } zone_id = aws_route53_zone.example.zone_id name = each.value.name type = each.value.type records = [each.value.value] ttl = 3600 }
先ほど作成した CloudFront Distribution にカスタムドメインと ACM 証明書を関連付けます。
resource "aws_cloudfront_distribution" "example" {
# ...
+ aliases = ["*.preview.classi.example"]
viewer_certificate {
- cloudfront_default_certificate = true
+ acm_certificate_arn = aws_acm_certificate.example.arn
+ ssl_support_method = "sni-only"
+ minimum_protocol_version = "TLSv1.2_2021"
}
}
これにより、例えば https://hoge.preview.classi.example のような URL で CloudFront Distribution にアクセスできるようになります。
CloudFront Function を作成する
リクエストされたドメインに応じて配信するファイルを切り替える CloudFront Function を作成します。
任意のファイル名で次の JavaScript プログラムを作成します。
処理内容の詳細についてはコメントをご参照ください。
// example.js function handler(event) { const request = event.request; const host = request.headers.host.value; // ページへのアクセスに対しては `index.html` を返す // (例: `/`, `/foo`, `/foo/bar` など) // 拡張子が含まれるファイルへのアクセスに対しては、そのまま返す // (例: `/foo.js`, `/foo/bar.css` など) if (!request.uri.includes(".")) { request.uri = "/index.html"; } // ドメインが `pr-<PR番号>.preview.classi.example` にマッチしない場合は 403 を返す const matches = host.match(/^pr-(\d+)\.preview\.classi\.example$/); if (!matches) { return { statusCode: 403, statusDescription: "forbidden" }; } // `s3://<S3バケット>/pull-requests/<PR番号>/<ファイルパス>` を配信する const pullRequestNumber = matches[1]; request.uri = `/pull-requests/${pullRequestNumber}${request.uri}`; return request; }
この JavaScript プログラムを使用する CloudFront Function を作成し、 CloudFront Distribution に関連付けます。
resource "aws_cloudfront_function" "example" { name = "<任意の関数名>" runtime = "cloudfront-js-2.0" code = file("${path.module}/path/to/example.js") }
resource "aws_cloudfront_distribution" "example" {
# ...
default_cache_behavior {
# ...
+ function_association {
+ event_type = "viewer-request"
+ function_arn = aws_cloudfront_function.example.arn
+ }
}
}
これにより、リクエストされたドメイン名やパスに応じて対応するファイルが配信されるようになります。
https://pr-111.preview.classi.example→s3://<S3_BUCKET>/pull-requests/111/index.htmlを返すhttps://pr-111.preview.classi.example/sub/page→s3://<S3_BUCKET>/pull-requests/111/index.htmlを返すhttps://pr-222.preview.classi.example/hoge/fuga.png→s3://<S3_BUCKET>/pull-requests/222/hoge/fuga.pngを返す

https://pr-111.preview.classi.example にアクセスする例

https://pr-222.preview.classi.example にアクセスする例
これでプレビュー環境をホスティングする仕組みの構築は完了です。
あとは Pull Request ごとに S3 バケット内に静的ファイルをアップロードすればいいだけです。
デプロイフロー
以下は Pull Request が作成 / 更新されるたびにプレビュー環境をデプロイする GitHub Actions ワークフローの例です。
処理内容はただ SPA をビルドして静的ファイルを S3 バケットの /pull-requests/<PR番号>/ 配下にアップロードするだけです。
name: Deploy Preview on: pull_request: branches: - main concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: publish: runs-on: ubuntu-latest permissions: contents: read id-token: write # OIDC による認証を有効にするために必要 steps: - uses: actions/checkout@v4 # アプリケーションのセットアップ ~ ビルドを行う # ...(省略) # プレビュー環境用の S3 バケットにファイルをアップロードする - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: <IAMロールのARN> aws-region: ap-northeast-1 - run: aws s3 sync "<ビルドしたファイル群が格納されているディレクトリのパス>" "s3://<プレビュー環境用のS3バケット>/pull-requests/${PR_NUMBER}/" --acl private env: PR_NUMBER: ${{ github.event.pull_request.number }} # 必要に応じて Pull Request にデプロイ結果等をコメントする # ...(省略)
これにより、 Pull Request を作成するたびに S3 バケットに SPA の静的ファイルがアップロードされ、 https://pr-<PR番号>.preview.classi.example からアプリケーションにアクセスできるようになります。
💡 上記の例ではわかりやすくするために
actions/checkoutやaws-actions/configure-aws-credentialsを Git タグで参照していますが、実際にはコミット SHA を指定して参照するのがセキュリティ的には望ましいです。# 例 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2リリースされたアクションバージョンのコミットSHAを使用するのが、安定性とセキュリティのうえで最も安全です。
実際、 tj-actions/changed-files や reviewdog/action-* などのアクションの Git タグが書き換えられ、これらのアクションを Git タグで参照している GitHub Actions Workflow 内で悪意のあるコードが実行されてしまうという出来事もありました。
なお、 Git タグからコミット SHA への書き換えは pinact などのツールによって自動化が可能です。
プレビュー環境の削除
プレビュー環境は一時的なものなので、レビュー後は削除する必要があります。
今回は S3 バケットのライフサイクルルール *2 で S3 バケット内のオブジェクトを自動削除することで、プレビュー環境の削除を実現します。
# 14日後にプレビュー環境用のS3バケットのオブジェクトを削除する設定 resource "aws_s3_bucket_lifecycle_configuration" "example" { # プレビュー環境用のS3バケット bucket = aws_s3_bucket.example.id rule { id = "expiration" status = "Enabled" # バケット全体に適用 filter { prefix = "" } expiration { days = 14 } } }
ライフサイクルルールを使わずに Pull Request がマージされたときに S3 からファイルを削除する GitHub Actions Workflow を構築してもいいのですが、そもそも実装コストがかかることや、実装に不具合があった場合などにファイルが削除されずに残り続ける可能性がある、という懸念があります。 ライフサイクルルールを使えば、複雑な Workflow を構築することなく削除漏れを防止できるという利点があります。
なお、 Pull Request が Open のままでも一定期間以上放置されるとプレビュー環境が消えてしまうというデメリット (?) はありますが、必要に応じてワークフローを Re-run するだけですぐ再デプロイが可能なので、大した問題にはなりません。
まとめ
今回の仕組みを導入してからフロントエンドの Pull Request レビューが大変捗るようになったので、とてもおすすめです。
参考
今回のこちらの仕組みについては以下の記事を参考にさせていただきました。
こちらの記事では CloudFront Function ではなく Lambda@Edge を利用したパターンが紹介されています。あわせてご参照ください。
Appendix
デプロイ完了後に Pull Request に URL 付きのコメントをつける
プレビュー環境のデプロイ完了後に Pull Request に URL 付きのコメントを作成するようにするとサクッとプレビュー環境にアクセスできるので便利です。

以下はプレビュー環境のデプロイ完了後に peter-evans/find-comment アクションと peter-evans/create-or-update-comment アクションを使用して Pull Request にコメントを作成する例です。
もしワークフローが複数回実行されても、複数のコメントが作成されるのではなく単一のコメントが更新され続けます。
name: Deploy Preview # ...(省略) jobs: publish: runs-on: ubuntu-latest permissions: # ...(省略) pull-requests: write # Pull Request にコメントを追加するために必要 steps: # プレビュー環境のデプロイ # ...(省略) # 2 週間後の日付 (= ライフサイクルルールによって削除される日付) を取得 - name: Get expiration date id: expiration env: TZ: Asia/Tokyo run: echo "date=$(date -d "+14 days" "+%Y-%m-%d %a")" >> "${GITHUB_OUTPUT}" # デプロイ完了後に Pull Request にコメントを追加する - uses: peter-evans/find-comment@v3 if: github.event_name == 'pull_request' id: find-comment with: issue-number: ${{ github.event.pull_request.number }} comment-author: 'github-actions[bot]' body-includes: 'Deployed to Preview Environment' - uses: peter-evans/create-or-update-comment@v4 if: github.event_name == 'pull_request' with: comment-id: ${{ steps.find-comment.outputs.comment-id }} issue-number: ${{ github.event.pull_request.number }} body: | ## :rocket: Deployed to Preview Environment :rocket: - **https://pr-${{ github.event.pull_request.number }}.preview.classi.example** (commit: ${{ github.event.pull_request.head.sha }}) :information_source: This preview environment will be deleted on _**${{ steps.expiration.outputs.date }}**_. edit-mode: replace
プレビュー環境での API 通信について
Classi の内部 CMS のフロントエンドアプリケーションでは元々ローカル開発用に msw を導入しており、プレビュー環境でもそれをそのまま流用して API 通信を全てモックにしています。
そのため、各プレビュー環境は backend とは切り離された状態での動作確認が可能となっています。
アプリケーションの特性によってはプレビュー環境でも実際の backend と通信させても問題ないものもあると思われるので、そこは要件に合わせて設計するといいでしょう。