ゴールと前提
この手順を終えると、main ブランチにpushするだけで、ビルドからS3への配置、CloudFrontのキャッシュ更新までが自動で走ります。アクセスキーは使いません。所要時間は1時間前後です。
構成を決めた理由や、OIDCのエラーでハマった経緯は体験記に書きました。
コードが書けないインフラエンジニアが、Claudeと作る S3+CloudFront+GitHub Actions のCI/CD
必要なもの
- AWSアカウント(IAM、S3、CloudFrontを操作できる権限)
- GitHubアカウント
- 手元でビルドする場合のみ Node.js 20以上とgit(CI上のビルドだけなら不要)
この手順書で置き換える値
| 表記 | 中身 | 例 |
|---|---|---|
<ACCOUNT_ID> |
AWSアカウントID(12桁) | 123456789012 |
<BUCKET> |
S3バケット名 | my-app-prod |
<DIST_ID> |
CloudFrontのディストリビューションID | E1ABCDEFGHIJK |
<OWNER> |
GitHubのユーザー名または組織名 | octocat |
<REPO> |
リポジトリ名 | my-app |
<OWNER_ID> / <REPO_ID>
|
GitHub上の数字ID(手順6で調べる) | 1234567 |
リージョンは東京(ap-northeast-1)を前提にしています。
全体構成
ソースはGitHub、成果物はS3に置き、ビルドはGitHubのランナーが担います。ランナーはOIDCでIAMロールを一時的に引き受けるので、長期のアクセスキーはどこにも置きません。
以降の手順1〜4で公開まで、手順5〜8で自動デプロイまでを作ります。
手順1 アプリを用意してビルドする
ビルドすると dist/ に配信用のファイル一式ができます。S3に置くのはこの中身だけです。
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run build
できた dist/ は次の形です。assets/ のファイル名には中身のハッシュが付き、コードを変えるたびに変わります。
dist/
├── index.html
├── favicon.svg
└── assets/
├── index-xxxx.js
└── index-xxxx.css
.gitignore に node_modules と dist が入っていることを確認します。成果物はCIが毎回作るので、リポジトリには入れません。
手順2 S3バケットを作る
バケットは非公開のまま作ります。公開はCloudFrontが担当します。
S3コンソールで「バケットを作成」を選び、次のように設定します。
| 項目 | 設定 |
|---|---|
| リージョン | ap-northeast-1 |
| バケットタイプ | 汎用 |
| バケット名 |
<BUCKET>(世界で一意) |
| オブジェクト所有者 | ACL無効(推奨) |
| パブリックアクセスをすべてブロック | オンのまま |
| 暗号化 | SSE-S3(デフォルト) |
CLIなら次の1行です。
aws s3 mb s3://<BUCKET> --region ap-northeast-1
初回の動作確認用に、dist/ の中身をバケット直下に上げておきます。dist フォルダごと上げると dist/index.html の位置になり、表示されません。
aws s3 sync dist/ s3://<BUCKET>/
やらないこと:静的ウェブサイトホスティングの有効化と、バケットポリシーの手動作成。OACはRESTエンドポイントを使い、ポリシーは手順3で自動付与されます。
手順3 CloudFrontディストリビューションを作る
CloudFrontがOACでS3を読み、HTTPSで配信します。作成ウィザードの画面構成は変わることがあるので、項目名より設定内容を合わせてください。
| 項目 | 設定 |
|---|---|
| 料金プラン | Free(月$0)で十分。WAFの基本保護も含まれる |
| S3オリジン |
<BUCKET>.s3.ap-northeast-1.amazonaws.com(s3-website の付く方は選ばない) |
| オリジンパス |
空欄(/ も入れない) |
| Grant CloudFront access to origin | Yes(OACを作成し、バケットポリシーを自動書き込み) |
| ビューワープロトコル | Redirect HTTP to HTTPS |
| キャッシュポリシー | CachingOptimized |
作成後に必ずやること
- 「一般」タブの「設定」を編集し、デフォルトルートオブジェクトに
index.htmlを入れる。ウィザードに項目がなく、空のままになりがちです - S3の「アクセス許可」→「バケットポリシー」に、次のポリシーが入っているか確認する。空ならCloudFrontの「ポリシーをコピー」から貼る
- 「最終変更日時」が日時表示になったら、「ディストリビューションドメイン名」(
xxxx.cloudfront.net)を開いて表示を確認する
{
"Version": "2008-10-17",
"Id": "PolicyForCloudFrontPrivateContent",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::<BUCKET>/*",
"Condition": {
"ArnLike": {
"AWS:SourceArn": "arn:aws:cloudfront::<ACCOUNT_ID>:distribution/<DIST_ID>"
}
}
}
]
}
このポリシーは公開設定ではないので、パブリックアクセスブロックがオンでも保存できます。S3のオブジェクトURLを直接開くとAccessDeniedになれば、非公開が効いています。
手順4 GitHubリポジトリを作ってpushする
GitHubで空のリポジトリ <REPO> を作り、ソースをpushします。READMEなどは追加せず空のまま作ります。
git init
git add .
git commit -m "first commit"
git branch -M main
git remote add origin https://github.com/<OWNER>/<REPO>.git
git push -u origin main
ブラウザのドラッグ&ドロップで上げると、.gitignore や .github/ など先頭が . のファイルが除外されることがあります。その場合は、GitHubの「Add file」→「Create new file」で直接作ります。
この時点でワークフローが入っていれば実行されますが、AWS側が未設定なので失敗します。問題ありません。
手順5 IAMにOIDCプロバイダーを作る
GitHubが発行するトークンをAWSに信頼させる設定です。アカウントに1つあれば、ほかのリポジトリでも使い回せます。
IAMコンソールの「IDプロバイダ」→「プロバイダを追加」で作ります。
| 項目 | 設定 |
|---|---|
| プロバイダのタイプ | OpenID Connect |
| プロバイダのURL | https://token.actions.githubusercontent.com |
| 対象者(Audience) | sts.amazonaws.com |
作成後のARNは arn:aws:iam::<ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com です。
手順6 デプロイ用IAMロールを作る
GitHub Actionsが引き受けるロールを作り、特定のリポジトリの main ブランチからだけ使えるように絞ります。ロール名は例えば github-actions-<REPO>-deploy です。
6-1 subの形式を確認する
GitHubから届く sub には2つの形式があります。信頼ポリシーの値と完全一致しないと Not authorized to perform sts:AssumeRoleWithWebIdentity になります。
| 形式 | 値 |
|---|---|
| 名前のみ(従来) | repo:<OWNER>/<REPO>:ref:refs/heads/main |
| ID付き | repo:<OWNER>@<OWNER_ID>/<REPO>@<REPO_ID>:ref:refs/heads/main |
IAMコンソールのロール作成ウィザードが作るのは前者ですが、筆者の環境では後者が届きました。どちらが届くかは、一度実行してCloudTrailで確かめるのが確実です(トラブルシューティング参照)。
数字IDは、ブラウザで次のURLを開き、"id" の値を見ればわかります。
-
<OWNER_ID>:https://api.github.com/users/<OWNER> -
<REPO_ID>:https://api.github.com/repos/<OWNER>/<REPO>(Privateリポジトリはログインが必要)
6-2 信頼ポリシー
ロール作成で「ウェブアイデンティティ」を選び、手順5のプロバイダと sts.amazonaws.com を指定します。GitHub organizationには個人アカウントでも <OWNER> を入れ、repositoryとbranchも空欄にしません。作成後、「信頼関係」タブで次の内容に置き換えます。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::<ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:<OWNER>@<OWNER_ID>/<REPO>@<REPO_ID>:ref:refs/heads/main"
}
}
}
]
}
従来形式が届く環境なら、sub を repo:<OWNER>/<REPO>:ref:refs/heads/main にします。ID付き形式は、名前を変えた後に第三者が同名のリポジトリを作っても引き受けられないので、より安全です。
6-3 権限ポリシー(インライン)
管理ポリシーは付けず、デプロイに必要な操作だけを許可します。ListBucket はバケット本体、PutObject と DeleteObject は /* 付きに指定します。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::<BUCKET>"
},
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::<BUCKET>/*"
},
{
"Effect": "Allow",
"Action": "cloudfront:CreateInvalidation",
"Resource": "arn:aws:cloudfront::<ACCOUNT_ID>:distribution/<DIST_ID>"
}
]
}
作成したロールのARNを控えておきます。
手順7 GitHubにVariablesを登録する
リポジトリの Settings → Secrets and variables → Actions → Variables タブの、Repository variables に3つ登録します。
| Name | Value |
|---|---|
AWS_ROLE_ARN |
arn:aws:iam::<ACCOUNT_ID>:role/<ロール名> |
S3_BUCKET |
<BUCKET> |
CLOUDFRONT_DISTRIBUTION_ID |
<DIST_ID> |
OIDCなので秘密情報はありません。ロールARNは知られても、信頼ポリシーで自分のリポジトリからしか使えないので、SecretsでなくVariablesで問題ありません。
Environment variablesは、ワークフローで environment: を指定したときだけ読まれます。この手順では使いません。
手順8 ワークフローを置いて実行する
.github/workflows/deploy.yml を次の内容で作り、main にコミットします。
name: Deploy to S3 + CloudFront
on:
push:
branches: [main]
workflow_dispatch:
permissions:
id-token: write # OIDCのトークン取得に必要
contents: read
concurrency:
group: deploy
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install & build
run: |
npm ci
npm run build
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: ap-northeast-1
- name: Upload to S3
run: |
aws s3 sync dist/ "s3://${{ vars.S3_BUCKET }}/" --delete \
--exclude index.html \
--cache-control "public,max-age=31536000,immutable"
aws s3 cp dist/index.html "s3://${{ vars.S3_BUCKET }}/index.html" \
--cache-control "no-cache" \
--content-type "text/html; charset=utf-8"
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id "${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}" \
--paths "/index.html" "/"
キャッシュの考え方:assets/ はファイル名にハッシュが入るので、永久キャッシュしても古い版は残りません。キャッシュさせないのは index.html だけなので、無効化も index.html だけで済みます。--delete で古いアセットは自動で削除されます。
動作確認
- Actionsタブで「Deploy to S3 + CloudFront」→「Run workflow」を実行する
- 全ステップが緑のチェックになれば成功
- 画面の文字を少し変えてpushし、1〜2分後にサイトを再読み込みして反映を確かめる
ビルドが失敗したときは、S3には何もアップロードされず、公開中のサイトはそのまま残ります。
トラブルシューティング
| 症状 | 原因 | 対処 |
|---|---|---|
| トップURLでAccessDenied(XML) | デフォルトルートオブジェクトが未設定、またはバケットポリシーがない | 手順3の「作成後に必ずやること」を確認 |
| 真っ白な画面 |
dist フォルダごと上げている |
バケット直下に index.html と assets/ がある形にする |
| Actionsが動かない |
.github/workflows/ が上がっていない |
GitHubの「Create new file」で作る |
Credentials could not be loaded |
permissions: id-token: write がない |
ワークフローに追加する |
Not authorized to perform sts:AssumeRoleWithWebIdentity |
信頼ポリシーの sub が届いた値と不一致 |
CloudTrailで実際の値を確認して合わせる(下記) |
| S3でAccessDenied | 権限ポリシーのバケット名や /* の指定誤り |
手順6-3を見直す |
| Variablesが空になる | Environment variablesに登録している | Repository variablesに登録し直す |
| Node.js 20の非推奨ワーニング |
configure-aws-credentials@v5 はNode.js 20向け |
@v6 に上げる |
届いた sub をCloudTrailで確認する
推測で直すより、拒否されたリクエストの中身を見るのが確実です。
- CloudTrailを開き、リージョンをap-northeast-1にする(ワークフローの
aws-regionと同じ) - 「イベント履歴」で、イベント名
AssumeRoleWithWebIdentityを絞り込む - 失敗したイベントの
userIdentity.userNameを見る。これが実際に届いたsubです - 信頼ポリシーの
subをその値にそろえ、Actionsで「Re-run jobs」を押す
イベント履歴への反映には数分かかることがあります。
費用の目安
個人規模の静的サイトなら、月額はほぼ0円です(2026年10月時点)。料金体系は変わるので、構築前に各公式ページで確認してください。
| サービス | 目安 |
|---|---|
| S3 | 数百KBで月数円以下 |
| CloudFront | Freeプランなら0円。従量課金の場合も無効化は月1,000パスまで無料 |
| GitHub Actions | Publicリポジトリは無料、Privateは月2,000分まで無料 |
| IAM、OIDCプロバイダー | 無料 |
| 独自ドメイン(任意) | Route 53のホストゾーン月$0.50+ドメイン代 |