CloudFront + S3 で SPA をちゃんと配信する
概要
React / Vue などの SPA(Single Page Application)を本番配信する定番構成が「S3 にビルド成果物を置き、CloudFront から配信する」パターンです。手順自体はシンプルですが、素直に作ると次のような落とし穴にハマります。
- リロードすると 403 / 404 になる(SPA ルーティングの罠)
index.htmlがキャッシュされ続けてデプロイが反映されない- S3 を公開設定にしてしまいセキュリティ的に不安が残る
この記事では、S3 バケットを非公開のまま、OAC(Origin Access Control)で CloudFront からのみアクセスさせ、SPA のルーティングとキャッシュ戦略まで含めて配信する構成をハンズオン形式でまとめます。
環境
- AWS アカウント(検証用の個人アカウントを想定)
- AWS CLI v2
- Node.js 20 以上
- 配信するもの: 任意の SPA のビルド成果物(
dist/またはbuild/) - リージョン: 東京(
ap-northeast-1)を基本。ただし後述の理由で ACM 証明書のみus-east-1
構成図(抽象化したレベル):
ユーザー │ HTTPS ▼ CloudFront (Distribution) │ OAC で署名付きリクエスト ▼ S3 (非公開バケット / 静的アセット)
発生した問題
SPA を「とりあえず配信」しようとすると、次の 3 つに必ずぶつかります。
1. リロードで 404 になる
SPA はクライアント側ルーティングです。/users/123 のような URL を直接開くと、S3 には users/123 というオブジェクトが存在しないため 404(バケットポリシー次第では 403)が返ります。トップページからの遷移では動くのに、リロードやURL直打ちだけ壊れる、という厄介な症状になります。
2. デプロイが反映されない
index.html に長い Cache-Control を付けると、新しいバージョンをアップロードしても CloudFront / ブラウザが古い index.html を返し続けます。逆に全ファイルをキャッシュ無効にすると CDN の意味が薄れます。
3. S3 を公開したくない
s3:GetObject を Principal: * で開けてしまう記事も多いですが、バケットを直接インターネットに晒すのは避けたいところです。OAC を使えば非公開のまま CloudFront 経由に限定できます。
原因
- 404 問題: S3 はあくまでオブジェクトストレージで、SPA のルーティングを知らない。存在しないキーは素直に 404 を返す。
- キャッシュ問題: ハッシュ付きアセット(
app.a1b2c3.js)とindex.htmlを同じキャッシュ戦略で扱っているため。 - 公開問題: S3 の静的ウェブサイトホスティング機能を使うと、バケットを public にする必要が出てくる。OAC を使えば非公開のまま CloudFront 経由に限定できる。
対応方法
方針は以下の通りです。
- S3 バケットは Block Public Access を全部 ON のままにする
- CloudFront に OAC を設定し、バケットポリシーで「この Distribution からのみ許可」する
- CloudFront の カスタムエラーレスポンスで、403/404 を
/index.html(200)にフォールバックさせる(SPA ルーティング対応) - キャッシュは
index.htmlは短命 / ハッシュ付きアセットは長命 に分ける
手順 1: S3 バケット作成とアップロード
# バケット作成(非公開のまま) aws s3 mb s3://example-spa-bucket --region ap-northeast-1 # Block Public Access を明示的に全 ON aws s3api put-public-access-block \ --bucket example-spa-bucket \ --public-access-block-configuration \ BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true # ビルド成果物をアップロード aws s3 sync ./dist s3://example-spa-bucket --delete
手順 2: OAC 付き CloudFront を作る
コンソールでも作れますが、再現性のため CDK(TypeScript)で書きます。ここが一番のキモです。
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as s3deploy from "aws-cdk-lib/aws-s3-deployment";
export class SpaHostingStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// 1. 非公開の S3 バケット
const bucket = new s3.Bucket(this, "SpaBucket", {
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
removalPolicy: cdk.RemovalPolicy.RETAIN, // 本番は誤削除防止
});
// 2. CloudFront + OAC(S3BucketOrigin.withOriginAccessControl が
// OAC の作成とバケットポリシー付与を自動でやってくれる)
const distribution = new cloudfront.Distribution(this, "SpaDistribution", {
defaultBehavior: {
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
viewerProtocolPolicy:
cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
},
defaultRootObject: "index.html",
// 3. SPA ルーティング対応:403/404 を index.html(200) にフォールバック
errorResponses: [
{
httpStatus: 403,
responseHttpStatus: 200,
responsePagePath: "/index.html",
ttl: cdk.Duration.seconds(0),
},
{
httpStatus: 404,
responseHttpStatus: 200,
responsePagePath: "/index.html",
ttl: cdk.Duration.seconds(0),
},
],
});
// 4. デプロイ:アセットは長命キャッシュ、index.html は別途上書き
new s3deploy.BucketDeployment(this, "DeploySpa", {
sources: [s3deploy.Source.asset("./dist")],
destinationBucket: bucket,
distribution,
distributionPaths: ["/*"], // デプロイ時に自動 invalidation
});
new cdk.CfnOutput(this, "DistributionDomain", {
value: distribution.distributionDomainName,
});
}
}
errorResponses で 403 も拾っているのがポイントです。OAC 構成では存在しないキーに対して S3 が 403 を返すため(s3:ListBucket 権限を渡していない場合)、404 だけ拾うと SPA ルーティングが動きません。
手順 3: index.html のキャッシュだけ短命にする
CDK の BucketDeployment だけだと index.html も含めて一律キャッシュになるので、CI で以下のように上書きするのが実務的です。
# ハッシュ付きアセットは長命(immutable) aws s3 sync ./dist s3://example-spa-bucket \ --exclude "index.html" \ --cache-control "public, max-age=31536000, immutable" # index.html は常に取り直させる aws s3 cp ./dist/index.html s3://example-spa-bucket/index.html \ --cache-control "no-cache, no-store, must-revalidate" # index.html だけ invalidation(コスト最小) aws cloudfront create-invalidation \ --distribution-id XXXXXXXXXXXX \ --paths "/index.html"
app.a1b2c3.js のようにファイル名にハッシュが入っていれば、内容が変われば URL も変わるため長命キャッシュで安全です。index.html だけ毎回取り直せば、新しいアセットへの参照が確実に配られます。
カスタムドメインを付ける場合
独自ドメイン + HTTPS にするなら ACM 証明書が必要です。CloudFront が参照する証明書は us-east-1(バージニア北部)で発行する必要がある点に注意してください。東京リージョンで作ると CloudFront に紐付けられません。
確認方法
デプロイ後、次を確認します。
# 1. トップページが 200 で返るか curl -I https://xxxxxxxxxxxxx.cloudfront.net/ # 2. 深いパスを直接叩いてリロード相当を再現(200 + index.html が返れば成功) curl -sI https://xxxxxxxxxxxxx.cloudfront.net/users/123 | head -n 1 # → HTTP/2 200 # 3. S3 を直接叩いて 403 になる(=公開されていない)ことを確認 curl -I https://example-spa-bucket.s3.ap-northeast-1.amazonaws.com/index.html # → 403 Forbidden なら OK(CloudFront 経由のみアクセス可) # 4. キャッシュヘッダの確認 curl -sI https://xxxxxxxxxxxxx.cloudfront.net/index.html | grep -i cache-control curl -sI https://xxxxxxxxxxxxx.cloudfront.net/assets/app.a1b2c3.js | grep -i cache-control
判定基準:
- 深いパスが 200 で
index.htmlの内容が返れば SPA ルーティング成功 - S3 直アクセスが 403 なら非公開が担保されている
index.htmlはno-cache、アセットはmax-age=31536000になっていれば OK
注意点
- OAC と OAI を混同しない: OAI(Origin Access Identity)は旧方式。新規構築は OAC を使う。SSE-KMS 暗号化との相性も OAC の方が良い。
- 403 も SPA フォールバックの対象に入れる: OAC 構成では 404 でなく 403 が返るケースがある。両方
errorResponsesに入れる。 - エラーフォールバックの副作用: 403/404 を一律 200 の
index.htmlにすると、本来 404 であるべき API 的な扱いもトップに落ちる。API は別オリジン / 別 Behavior に分離する。 - invalidation はコストに注意:
/*の invalidation は月 1000 パスまで無料だが、毎デプロイで多用すると課金対象になる。ハッシュ付きアセット運用にしてindex.htmlだけ invalidate するのが安上がり。 - 証明書は
us-east-1: これは仕様。忘れると詰まる。
まとめ
- S3 は 非公開のまま、OAC で CloudFront からのみ読ませる
- SPA のリロード 404 は 403/404 →
/index.html(200) のカスタムエラーレスポンスで解決 - キャッシュは
index.html短命 / ハッシュ付きアセット長命 で分ける - カスタムドメインの証明書は
us-east-1で発行
この 4 点を押さえれば、安全で反映の速い SPA 配信基盤が作れます。CDK にしておくと環境の再現とレビューが楽になるので、コンソール手作業より最初から IaC 化するのがおすすめです。
