0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【手順書】S3+CloudFront(OAC)+GitHub Actions(OIDC)で静的サイトのCI/CDを構築する

0
Last updated at Posted at 2026-10-08

ゴールと前提

この手順を終えると、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

作成後に必ずやること

  1. 「一般」タブの「設定」を編集し、デフォルトルートオブジェクトに index.html を入れる。ウィザードに項目がなく、空のままになりがちです
  2. S3の「アクセス許可」→「バケットポリシー」に、次のポリシーが入っているか確認する。空ならCloudFrontの「ポリシーをコピー」から貼る
  3. 「最終変更日時」が日時表示になったら、「ディストリビューションドメイン名」(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 で古いアセットは自動で削除されます。

動作確認

  1. Actionsタブで「Deploy to S3 + CloudFront」→「Run workflow」を実行する
  2. 全ステップが緑のチェックになれば成功
  3. 画面の文字を少し変えて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で確認する

推測で直すより、拒否されたリクエストの中身を見るのが確実です。

  1. CloudTrailを開き、リージョンをap-northeast-1にする(ワークフローの aws-region と同じ)
  2. 「イベント履歴」で、イベント名 AssumeRoleWithWebIdentity を絞り込む
  3. 失敗したイベントの userIdentity.userName を見る。これが実際に届いた sub です
  4. 信頼ポリシーの 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+ドメイン代
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?