AWS

CloudFrontとS3で静的サイトを公開する手順:OAC・独自ドメイン・デプロイまでCLIで構築

Adureを利用したインフラ構築

CloudFrontとS3の組み合わせは、コーポレートサイトやSPAを月数百円から配信できる定番の構成です。ただし、S3の静的ウェブサイトホスティングを有効にしてしまう、証明書を東京リージョンで発行してしまう、サブディレクトリが403になる、といった詰まりどころが工程ごとにあります。この記事では、非公開のS3バケット作成から、us-east-1でのACM証明書発行、OAC付きディストリビューション、Route 53のエイリアス、index.htmlの補完とSPAのルーティング、aws s3 syncによるデプロイまでを、コピーして動かせるCLIと設定例で通します。仕様は2026年10月時点の公式ドキュメントで確認したものです。

まとめ:CloudFrontとS3で静的サイトを公開するときの構成と手順の要点

構成は「ブロックパブリックアクセスを有効にしたままのS3バケット」を、OAC(オリジンアクセスコントロール)でCloudFrontだけに読ませる形が基本です。S3の静的ウェブサイトホスティング機能は使いません。ウェブサイトエンドポイントをオリジンにするとOACが使えず、バケットを公開せざるを得なくなるからです。

作業の順序は、バケット作成、us-east-1での証明書発行、OACとディストリビューション作成、バケットポリシー適用、Route 53のエイリアス作成です。証明書のDNS検証に時間がかかるため、ディストリビューションより先に発行を始めます。

公開後に最初に困るのは、/about/ のような末尾スラッシュのURLが403になる件です。デフォルトルートオブジェクトはルートにしか効きません。静的サイトはCloudFront Functionsでindex.htmlを補い、SPAはカスタムエラー応答でindex.htmlを返します。後者はディストリビューション全体に効くため、同じディストリビューションでAPIも配信するなら使えません。

デプロイはHTMLとハッシュ付きアセットでCache-Controlを分けてsyncし、最後に /* を無効化します。無効化はアカウント全体で月1,000パスまで無料で、/* は1パスとして数えられます。

S3をウェブサイト機能なしの非公開バケットとして作成するオリジン準備

最初にオリジンとなるS3バケットを作ります。ここでの判断はひとつだけで、S3の静的ウェブサイトホスティングを有効にしないことです。

ウェブサイトエンドポイントを選ぶとOACが使えずバケット公開になる理由

S3には、通常のRESTエンドポイント(bucket.s3.ap-northeast-1.amazonaws.com)と、静的ウェブサイトホスティング用のウェブサイトエンドポイント(bucket.s3-website-ap-northeast-1.amazonaws.com)の2つの入口があります。CloudFrontの公式ドキュメントは、ウェブサイトエンドポイントはカスタムオリジンとして扱われ、OACもOAIも使えないと明記しています。

OACが使えないと、CloudFrontに読ませるにはバケットを誰でも読める状態にするしかありません。S3のURLを知っていればCloudFrontを迂回して直接取得でき、WAFもキャッシュも素通りされます。ウェブサイトエンドポイントを選ぶ理由として挙がるのは「サブディレクトリでindex.htmlが返る」点ですが、これはCloudFront Functionsで代替できるため、非公開のRESTエンドポイントを選びます。

ブロックパブリックアクセスを有効のままバケットを作るCLIと確認

2023年4月以降に作成するバケットは、ブロックパブリックアクセスが全項目有効、オブジェクト所有者は「バケット所有者の強制」(ACL無効)が既定です。OACはこの「バケット所有者の強制」を前提にしているため、既定のまま作れば追加の設定はいりません。

# 東京リージョンに非公開のバケットを作る
BUCKET=example-com-site
aws s3api create-bucket --bucket "$BUCKET" --region ap-northeast-1 \
  --create-bucket-configuration LocationConstraint=ap-northeast-1

# 既定値の確認:4項目すべて true、ObjectOwnership が BucketOwnerEnforced なら正しい
aws s3api get-public-access-block --bucket "$BUCKET"
aws s3api get-bucket-ownership-controls --bucket "$BUCKET"

ap-northeast-1 以外で作る場合も LocationConstraint の指定が必要で、us-east-1 だけは省略します。既存バケットを流用するときは、ACLで公開設定が残っていないかを確かめてから使う手順です。バケット作成と権限設計の全体はAWS S3の使い方(CLIでのバケット作成から権限設計まで)で扱っています。

us-east-1の証明書とOAC付きディストリビューションを作成する設定手順

次にCloudFront側を作ります。独自ドメインでHTTPS配信するなら、ディストリビューションの作成時点で証明書のARNが必要です。証明書の発行から始めます。

証明書をus-east-1で発行してDNS検証のCNAMEを取り出すコマンド

CloudFrontの証明書要件では、ビューワーとCloudFront間のHTTPSに使うACM証明書は米国東部(バージニア北部、us-east-1)で発行またはインポートする必要があります。東京リージョンで発行した証明書は、CloudFrontの設定画面の候補に出てきません。

# 証明書は必ず us-east-1 で発行する
CERT_ARN=$(aws acm request-certificate --region us-east-1 \
  --domain-name www.example.com \
  --subject-alternative-names example.com \
  --validation-method DNS \
  --query CertificateArn --output text)

# DNS検証用のCNAME(Name と Value)を取り出し、Route 53 に登録する
aws acm describe-certificate --region us-east-1 --certificate-arn "$CERT_ARN" \
  --query 'Certificate.DomainValidationOptions[].ResourceRecord'

# Status が ISSUED になるまで待つ
aws acm wait certificate-validated --region us-east-1 --certificate-arn "$CERT_ARN"

apex(example.com)とwww の両方で配信するなら、SANに両方を入れておきます。後からCloudFrontに代替ドメイン名を足すとき、証明書のSANに含まれていない名前は登録できません。証明書の有効期間や更新の扱いはAWS Certificate Managerとはで整理しています。

OACを作成してS3オリジンに関連付けるディストリビューション設定JSON

OACは署名方式を「常に署名(always)」で作ります。公式ドキュメントによると、always を選んだときに限り、CloudFrontとS3の間の通信は常にHTTPSになります。

# OACを作成し、IDを控える
OAC_ID=$(aws cloudfront create-origin-access-control \
  --origin-access-control-config Name=example-com-oac,SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=s3 \
  --query OriginAccessControl.Id --output text)

# dist.json(OAC_ID と証明書ARNを差し替える)
{
  "CallerReference": "example-com-20261003",
  "Comment": "example.com static site",
  "Enabled": true,
  "DefaultRootObject": "index.html",
  "Aliases": { "Quantity": 2, "Items": ["www.example.com", "example.com"] },
  "Origins": { "Quantity": 1, "Items": [{
    "Id": "s3-site",
    "DomainName": "example-com-site.s3.ap-northeast-1.amazonaws.com",
    "OriginAccessControlId": "E2QWRUHEXAMPLE",
    "S3OriginConfig": { "OriginAccessIdentity": "" }
  }]},
  "DefaultCacheBehavior": {
    "TargetOriginId": "s3-site",
    "ViewerProtocolPolicy": "redirect-to-https",
    "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
    "Compress": true
  },
  "ViewerCertificate": {
    "ACMCertificateArn": "arn:aws:acm:us-east-1:111122223333:certificate/EXAMPLE",
    "SSLSupportMethod": "sni-only",
    "MinimumProtocolVersion": "TLSv1.2_2021"
  },
  "IsIPV6Enabled": true
}

aws cloudfront create-distribution --distribution-config file://dist.json

DomainName にはリージョン付きのRESTエンドポイントを書きます。CachePolicyId の値はAWSの管理ポリシー「CachingOptimized」のIDで、オリジンが返すCache-Controlを尊重しつつ、クエリ文字列とCookieをキャッシュキーから外します。S3OriginConfig の OriginAccessIdentity は空文字で残すのが、OACを使うときの書き方です。

ディストリビューションIDを条件に入れたバケットポリシーの適用

OACを付けただけでは、S3は403を返します。バケットポリシーでCloudFrontのサービスプリンシパルに読み取りを許可し、条件に指定するのは、自分のディストリビューションのARNです。条件を外すと、他人のCloudFrontディストリビューションからもこのバケットを読めてしまいます。

# policy.json(アカウントIDとディストリビューションIDを差し替える)
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "AllowCloudFrontServicePrincipalReadOnly",
    "Effect": "Allow",
    "Principal": { "Service": "cloudfront.amazonaws.com" },
    "Action": "s3:GetObject",
    "Resource": "arn:aws:s3:::example-com-site/*",
    "Condition": {
      "StringEquals": {
        "AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/EDFDVBD6EXAMPLE"
      }
    }
  }]
}

aws s3api put-bucket-policy --bucket example-com-site --policy file://policy.json

s3:ListBucket を許可していないため、存在しないパスへのリクエストにS3は404ではなく403を返します。この挙動は、次の章で説明するSPA対応に関わる仕様です。OACの署名動作の3つの選択肢やOAIからの移行手順はCloudFront OACとは(OAIとの違いとバケットポリシー設定手順)で詳しく解説しています。

Route 53のエイリアスで独自ドメインを向けて配信経路を確認する手順

ディストリビューションの状態が Deployed になったら、ドメインをCloudFrontへ向けます。

AレコードとAAAAレコードをエイリアスで作るchange-batchの書き方

Route 53の公式ドキュメントでは、CloudFrontへはCNAMEではなくエイリアスレコードを使います。エイリアスならapex(example.com)にも設定でき、CloudFrontへのエイリアスクエリには料金がかかりません。ディストリビューションでIPv6を有効にしている場合は、AレコードとAAAAレコードの2本が必要です。

# alias.json(DNSName はディストリビューションのドメイン名に差し替える)
{
  "Changes": [
    { "Action": "UPSERT", "ResourceRecordSet": {
        "Name": "www.example.com", "Type": "A",
        "AliasTarget": { "HostedZoneId": "Z2FDTNDATAQYW2",
          "DNSName": "d111111abcdef8.cloudfront.net", "EvaluateTargetHealth": false } } },
    { "Action": "UPSERT", "ResourceRecordSet": {
        "Name": "www.example.com", "Type": "AAAA",
        "AliasTarget": { "HostedZoneId": "Z2FDTNDATAQYW2",
          "DNSName": "d111111abcdef8.cloudfront.net", "EvaluateTargetHealth": false } } }
  ]
}

aws route53 change-resource-record-sets \
  --hosted-zone-id Z0123456789EXAMPLE --change-batch file://alias.json

HostedZoneId の Z2FDTNDATAQYW2 は、CloudFrontをエイリアス先にするときの固定値です。自分のホストゾーンのIDと取り違えると、エラーで登録できません。apex 用にも同じ2本を作ります。

CloudFront経由は200でS3直アクセスは403になることをcurlで確かめる

# CloudFront経由:200 と x-cache が返る
curl -sI https://www.example.com/ | grep -i -E '^(HTTP|x-cache|content-type)'

# S3のRESTエンドポイントへ直接:403 AccessDenied が正しい状態
curl -s -o /dev/null -w '%{http_code}\n' \
  https://example-com-site.s3.ap-northeast-1.amazonaws.com/index.html

2本目が200を返すなら、バケットのどこかが公開されています。ブロックパブリックアクセスが解除されていないか、古いバケットポリシーに Principal “*” の文が残っていないかを確認します。

サブディレクトリのindex.htmlとSPAのルーティングを動かす2つの方法

公開直後に、トップページは表示されるのに /about/ が403になる、という事象がよく起きます。原因は仕様で、静的サイトとSPAで直し方が違います。

デフォルトルートオブジェクトがルートURLにしか効かない仕様と403

デフォルトルートオブジェクトの公式ドキュメントは、index.html を指定しても /install/ のようなサブディレクトリへのリクエストには返さないと明記しています。S3のウェブサイトエンドポイントのインデックスドキュメントがサブディレクトリでも効くのとは挙動が異なります。

CloudFrontは /about/ をそのままS3に問い合わせ、S3には about/ というキーが無いため403になります。Hugo、Astro、Next.js の静的書き出しのように、ページごとに about/index.html を出力するジェネレーターでは、ほぼ確実にこの問題に当たる構成です。なお、デフォルトルートオブジェクトに “/index.html” と先頭スラッシュ付きで書くと、それ自体が403の原因になります。

CloudFront Functionsで末尾/にindex.htmlを補完する関数実装

静的サイトでは、ビューワーリクエストの段階でURIを書き換えます。AWSが公式の関数例として公開しているコードを使います(JavaScript runtime 2.0)。同じ関数はaws-samplesのGitHubリポジトリにもあります。

// add-index.js:末尾が / なら index.html を、拡張子が無ければ /index.html を補う
async function handler(event) {
    var request = event.request;
    var uri = request.uri;
    if (uri.endsWith('/')) {
        request.uri += 'index.html';
    } else if (!uri.includes('.')) {
        request.uri += '/index.html';
    }
    return request;
}

# 関数を作成して公開する(ETag は describe-function で取得)
aws cloudfront create-function --name add-index-html \
  --function-config Comment="append index.html",Runtime=cloudfront-js-2.0 \
  --function-code fileb://add-index.js
ETAG=$(aws cloudfront describe-function --name add-index-html --query ETag --output text)
aws cloudfront publish-function --name add-index-html --if-match "$ETAG"

公開した関数は、DefaultCacheBehavior の FunctionAssociations に EventType “viewer-request” で関連付けます。拡張子の無いURLをすべてディレクトリとみなすため、/api/users のような拡張子無しのパスを同じビヘイビアで別のオリジンへ流す構成では、パスごとにビヘイビアを分けてから関連付けます。

SPAで403と404をindex.htmlの200に置き換えるカスタムエラー応答

ReactやVueのSPAは、/users/42 のようなURLをブラウザ側のルーターが解釈します。S3にそのキーは無いので、カスタムエラー応答で403と404を index.html の200に置き換えます。

"CustomErrorResponses": {
  "Quantity": 2,
  "Items": [
    { "ErrorCode": 403, "ResponsePagePath": "/index.html", "ResponseCode": "200", "ErrorCachingMinTTL": 10 },
    { "ErrorCode": 404, "ResponsePagePath": "/index.html", "ResponseCode": "200", "ErrorCachingMinTTL": 10 }
  ]
}

注意点は、カスタムエラー応答がキャッシュビヘイビア単位ではなくディストリビューション全体に効くことです。同じディストリビューションの /api/* でAPI Gatewayを配信していると、APIの403や404までHTMLの200に化け、フロントエンドのエラー処理が動かなくなります。APIを同居させるなら、カスタムエラー応答は使わず、上の関数を「拡張子の無いパスは /index.html へ書き換える」形に改めてSPA用のビヘイビアだけに関連付けます。

aws s3 syncとキャッシュ無効化で静的サイトのデプロイを自動化する

ファイルの配置は aws s3 sync で行います。ポイントはCache-Controlをファイルの種類で分けることと、無効化の数え方です。

HTMLとハッシュ付きアセットでCache-Controlを分けるsyncの3段実行

#!/usr/bin/env bash
# deploy.sh:dist/ の内容をS3へ反映し、CloudFrontのキャッシュを無効化する
set -euo pipefail
BUCKET=example-com-site
DIST_ID=EDFDVBD6EXAMPLE

# 1. ファイル名にハッシュが付くアセットは1年キャッシュ
aws s3 sync ./dist "s3://$BUCKET" --exclude "*.html" \
  --cache-control "public, max-age=31536000, immutable"

# 2. HTMLは毎回オリジンへ確認させる
aws s3 sync ./dist "s3://$BUCKET" --exclude "*" --include "*.html" \
  --cache-control "public, max-age=0, must-revalidate"

# 3. 新しい版をすべて置いてから、消えたファイルを削除する
aws s3 sync ./dist "s3://$BUCKET" --delete

# 4. 全パスを1件の無効化として送る
aws cloudfront create-invalidation --distribution-id "$DIST_ID" --paths "/*"

削除を最後に回しているのは、古いHTMLがまだ参照しているアセットを先に消すと、配信中のページが一時的に壊れるからです。s3 syncのリファレンスのとおり、syncが転送対象を決める基準は、ファイルのサイズと更新日時です。中身を変えずにCache-Controlだけを変えたいときは、syncでは再送されないため、aws s3 cp に –metadata-directive REPLACE を付けて上書きします。cp の詳しいオプションはaws s3 cpの使い方にまとめています。

無効化パスは月1,000件まで無料のため/*を1パスで送る運用

無効化料金の公式ドキュメントによると、無料なのはアカウント全体で月1,000パスまでです。/images/* のようにワイルドカードを含むパスは、何千ファイルを消しても1パスとして数えられます。逆に、変更したファイルを1つずつ並べると、ファイル数がそのままパス数になります。

1日に数回デプロイする程度なら、毎回 /* を1パスで送れば無料枠を超えることはまずありません。CIから変更ファイルの一覧を作って個別に無効化するより、/* のほうが料金と実装の両面で単純です。キャッシュタグによる無効化も同じ月1,000パスの枠で数えられます。

定額プランでS3とCloudFrontの構成を運用するときに詰まる加入条件

CloudFrontには従量課金のほかに、月額固定の定額料金プランがあります。静的サイトとの相性は良い一方で、構成によっては加入できません。

OAIが残る旧構成とほかと共有するCloudFront Functionsは加入不可

定額プランに加入できない構成として、公式ドキュメントはOAIの利用を挙げ、OACへの切り替えを求めています。数年前にOAIで作ったS3配信をそのまま定額プランへ移そうとすると、ここで止まります。

見落としやすいのが関数の共有です。ほかのディストリビューションにも関連付けているCloudFront Functionsは、定額プランのディストリビューションには付けられません。前の章の add-index-html を複数サイトで使い回しているなら、サイトごとに関数を複製します。WAFのWeb ACLの関連付けが必須で、無料利用枠を使っているアカウントは加入できない点も条件に入ります。

Freeプランの上限と毎月5GBから付くS3ストレージクレジットの扱い

プラン 月間リクエスト 月間データ転送 S3ストレージクレジット キャッシュビヘイビア数
Free 100万 100GB 5GB 5
Pro 1,000万 50TB 50GB 10
Business 1億2,500万 50TB 1TB 50
Premium 5億 50TB 5TB 100

S3ストレージクレジットは、オリジンに使っているかどうかに関係なく、アカウントのS3 Standardの保存料金に充当されます。数百MBのコーポレートサイトならFreeの5GBで保存料金をほぼまかなえる計算です。従量課金の場合も、S3などAWS上のオリジンからCloudFrontへの転送はCloudFrontの料金ページで無料とされています。各プランの月額と従量課金との分岐点はCloudFrontの料金を実額で計算する記事で計算しています。

S3とCloudFrontを選ぶ案件とAmplify Hostingに任せる案件の判断基準

ここまでの構成は手順が多く、Amplify Hostingなら数クリックで済む部分もあります。どちらを選ぶかは、サーバー側の処理の有無と、インフラを誰が持つかで決まります。

S3とCloudFrontで自前構築する条件と見送ってAmplifyへ回す場面

S3とCloudFrontを自前で組むべきなのは、配信物が完全に静的で、WAFのルール・セキュリティヘッダー・キャッシュの方針を自社で管理したい場合です。既存のAWSアカウントでTerraformやCDKによりインフラを管理しているなら、この構成をコードに落とすのが自然です。コーポレートサイト、LP、SSGで書き出したドキュメントサイト、APIを別ドメインに置くSPAが該当します。

見送るのは、Next.jsのSSRやISRのようにサーバー側の描画が必要な場合です。S3とCloudFrontだけでは動かず、Lambdaを組み合わせて自作することになり、Amplify Hostingのほうが早く安定します。ブランチごとのプレビュー環境が欲しい、インフラ担当がいない、という条件でも同様です。比較の詳細はAWS Amplify Hostingとは(S3+CloudFrontとの違い)で扱っています。

迷うのは、静的サイトにお問い合わせフォームだけ付けたいケースです。フォームのためにSSR基盤へ移る必要はなく、S3とCloudFrontのまま、送信先だけAPI GatewayとLambdaで用意すれば足ります。配信基盤の構築やIaC化を外部に任せたい場合は、インフラ構築(AWS・Google Cloud・Azure)でご相談いただけます。

よくある質問

CloudFrontとS3の構成について、検索されることの多い5つの疑問に答えます。仕様は2026年10月時点の公式ドキュメントに基づきます。

S3の静的ウェブサイトホスティングは有効にする必要がありますか?

必要ありません。有効にしてウェブサイトエンドポイントをオリジンにすると、CloudFrontからはカスタムオリジンとして扱われ、OACが使えなくなります。バケットを公開しなければならず、S3のURLから直接取得される状態になります。サブディレクトリのindex.htmlはCloudFront Functionsで補えるため、ウェブサイトホスティングは無効のまま、RESTエンドポイントをオリジンにしてください。

CloudFront経由で403 Access Deniedが出るのはなぜですか?

多いのは3つです。バケットポリシーの AWS:SourceArn に書いたディストリビューションIDが違う、/about/ のような末尾スラッシュのURLでindex.htmlが補われていない、存在しないファイルを要求している、のいずれかです。s3:ListBucket を許可していない構成では、存在しないファイルにもS3は404ではなく403を返します。OAC側の確認手順はCloudFront OACの記事にまとめています。

S3からCloudFrontへのデータ転送に料金はかかりますか?

かかりません。S3などAWS上のオリジンからCloudFrontへの転送は無料です。料金が発生するのはCloudFrontから利用者への転送とリクエスト、S3の保存料金とCloudFrontからS3へのリクエストです。キャッシュが効いているほどS3へのリクエストは減ります。定額プランなら利用者への転送も月額に含まれ、S3の保存料金にもクレジットが充当されます。

ファイルを更新したのに古いページが表示されるのはなぜですか?

CloudFrontのエッジに古い版がキャッシュされているためです。デプロイ後に /* の無効化を送るか、HTMLに max-age=0 の Cache-Control を付けて毎回オリジンへ確認させます。無効化は反映まで数分かかることがあります。ブラウザ側のキャッシュが残っている場合もあるため、確認はシークレットウィンドウか curl で行うと確実です。

OAIで作った既存の構成はOACへ移行する必要がありますか?

動いている構成がすぐ止まるわけではありませんが、移行を勧めます。AWSはOAIをレガシーと位置づけ、SSE-KMSで暗号化したオブジェクトや新しいリージョンのバケットに対応していません。定額料金プランもOAIのままでは加入できません。バケットポリシーにOAIとOACの両方を許可する文を並べてから切り替えれば、無停止で移行できます。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.09.05 コラム eKYCとは?方式の違いと2027年4月の犯収法改正で変わる本人確認要件
  2. 2026.10.03 テックブログ AWS Snowconeとは:サービス終了後の現状とDataSync・Greengrassへの移行手順【2026年版】
  3. 2026.10.03 テックブログ foliumとは:Pythonで地図を作る使い方・タイルの注意点・1.0候補版の変更点【2026年版】
  4. 2026.10.03 コラム ワークフローシステムの通知機能の設計:承認を止めないリマインド・催促と宛先の絞り方
  5. 2026.10.02 コラム ワークフローシステムを無料で使う4つの方法|無料プランとOSSの制限・有料化の判断

RELATED POSTS 関連記事

目次