GitHub Actions で S3 + CloudFront へ自動デプロイ
はじめに
個人のポートフォリオサイトを、GitHub Actions で AWS の S3 + CloudFront に自動デプロイしています。サイトはビルド不要の静的サイト(HTML / CSS / JavaScript)で、main ブランチに push すると数十秒で本番に反映されます。
この仕組みは 2026 年 4 月に作りましたが、最初の形のまま使い続けてきたわけではありません。運用している間に、デプロイ先を一本化し、公開するファイルの選び方を変え、非推奨になった Node.js 20 への対応も行いました。
この記事では、まず現在の構成を説明し、そのあとで3回の変更について「何に困って、どう直したか」を順に紹介します。
対象読者は次のような方です。
- 静的サイトを GitHub Actions で S3 + CloudFront に自動デプロイしたい方
- すでにデプロイ用の workflow を持っていて、見直すきっかけを探している方
現在の構成
workflow の全体像
workflow は .github/workflows/deploy.yml の1ファイルで、prepare と deploy-aws の2つのジョブで構成しています。
name: Deploy Portfolio
on:
push:
branches:
- main
workflow_dispatch:
concurrency:
group: deploy-production
cancel-in-progress: true
jobs:
prepare:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Create deployment bundle
shell: bash
run: |
mkdir -p _site
rsync -av ./*.html _site/
rsync -av ./static/ _site/static/
- name: Upload deployment bundle
uses: actions/upload-artifact@v7
with:
name: site-bundle
path: _site
deploy-aws:
needs: prepare
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
env:
AWS_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}
CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }}
steps:
- name: Download deployment bundle
uses: actions/download-artifact@v8
with:
name: site-bundle
path: _site
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
aws-region: ${{ env.AWS_REGION }}
role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
- name: Sync files to S3
run: aws s3 sync ./_site "s3://${S3_BUCKET_NAME}" --delete
- name: Invalidate CloudFront cache
run: aws cloudfront create-invalidation --distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" --paths "/*"
prepare ジョブは、公開するファイルだけを _site/ にまとめ、artifact としてアップロードします。deploy-aws ジョブはその artifact を受け取り、aws s3 sync --delete で S3 と同期したあと、CloudFront のキャッシュを /* で無効化します。--delete を付けているので、リポジトリから消したファイルは S3 からも消えます。
main 以外のブランチで動作を試したいときは、workflow_dispatch で手動実行できます。また、concurrency で同じグループのデプロイが重なったときは古い実行をキャンセルするので、短い間隔で push しても最後の内容が反映されます。
リージョン、バケット名、Distribution ID、IAM Role の ARN は、すべてリポジトリの Secrets から参照しています。
OIDC で AWS に入る
AWS の認証には、アクセスキーではなく GitHub の OIDC を使っています。deploy-aws ジョブに id-token: write の権限を与え、aws-actions/configure-aws-credentials で IAM Role を引き受けます。GitHub の Secrets に長期間有効なアクセスキーを保存しなくて済むのが、この方式の利点です。
IAM Role の信頼ポリシーでは、token.actions.githubusercontent.com:sub の条件を次の値にしています。
repo:Termnix-IT/portfolio-site:ref:refs/heads/main
ワイルドカードは使わず、このリポジトリの main ブランチから実行された workflow だけが Role を引き受けられるように絞っています。
Role に必要な権限は、デプロイで使う次のものだけです。
s3:ListBuckets3:GetObjects3:PutObjects3:DeleteObjectcloudfront:CreateInvalidation
変遷1: GitHub Pages と AWS の二重デプロイをやめた
最初の workflow には、deploy-aws とは別に deploy-pages というジョブがあり、同じファイル群を GitHub Pages と AWS の両方にデプロイしていました。prepare で作った artifact を2つのジョブで使い回す構成で、ジョブを分けているのはこの名残です。
2026 年 5 月に、GitHub Pages 側のジョブを削除して AWS に一本化しました。理由は2つあります。
1つ目は、2か所へのデプロイを両方とも管理し続けるコストです。デプロイ先が2つあると、設定の管理もデプロイ先の数だけ必要になります。
2つ目は、サイトの機能が AWS でしか成り立たなくなったことです。問い合わせフォームは、AWS Lambda の Function URL にフォームの内容を JSON で POST し、SES でメールを送る仕組みにしています。また、ドメインや SES、Lambda などの周辺リソースは Terraform で管理しています。GitHub Pages 側では同じサイトを再現できないため、二重に持っておく意味がなくなりました。
変遷2: 除外リスト方式から列挙方式へ
最初の workflow では、リポジトリ全体を _site/ にコピーし、公開しないファイルを --exclude で1つずつ除いていました。
rsync -av \
--exclude ".git/" \
--exclude ".github/" \
--exclude ".gitignore" \
--exclude "README.md" \
--exclude "docs/" \
--exclude "_site/" \
--exclude "CLAUDE.md" \
./ _site/
この方式では、公開しないファイルがリポジトリに増えるたびに、除外リストへの追記が必要になります。実際に、運用ドキュメントの MAINTENANCE.md を追加したときと、ドキュメントを docs/ にまとめたときに、それぞれ workflow を修正しました。
ファイル名の変更にも追従が必要です。README のファイル名を README.MD から README.md に変えたとき、rsync 側の除外指定は同じコミットで直しました。一方で、aws s3 sync 側に書いていた --exclude "README.MD" は古い表記のまま残っていました。rsync の段階で README はすでに除外されていたため実害はありませんでしたが、除外指定が2か所に分かれていると、片方だけ直し忘れることがあります。
除外リスト方式には、追記を忘れると公開すべきでないファイルが S3 に出てしまうという問題もあります。そこで 2026 年 5 月に、公開するもの(ルートの *.html と static/)だけを列挙する方式に切り替えました。
mkdir -p _site
rsync -av ./*.html _site/
rsync -av ./static/ _site/static/
この変更で、リポジトリに README やドキュメントを追加しても、workflow を触る必要がなくなりました。aws s3 sync 側の --exclude も不要になったので、あわせて削除しました。
列挙方式では、逆に「公開したいファイルを列挙し忘れる」ことが起こり得ます。このサイトは公開するファイルがルートの HTML と static/ 配下に限られているので、列挙する対象が少なく、この方式が合っていました。
変遷3: Node.js 20 非推奨への対応
2026 年 9 月に、workflow で使っている action がすべて Node.js 20 ベースであり、runner によって Node.js 24 で強制的に実行されていることに気づきました。宣言しているランタイムと実際に動いているランタイムが食い違っている状態だったので、各 action を現行のメジャーバージョンに上げました。
| action | 変更前 | 変更後 |
|---|---|---|
actions/checkout |
v4 | v7 |
actions/upload-artifact |
v4 | v7 |
actions/download-artifact |
v4 | v8 |
aws-actions/configure-aws-credentials |
v4 | v6 |
メジャーバージョンを上げる前に、それぞれの破壊的変更がこの workflow に影響しないかを確認しました。
-
actions/download-artifactの v5 では、ダウンロード先のパスの扱いが変わりました。ただし、この変更は artifact を ID で指定してダウンロードする場合のもので、名前(site-bundle)で指定しているこの workflow には影響しません。 -
aws-actions/configure-aws-credentialsの v5 では、入力値の扱いが変わりました。ただし、この変更は真偽値の入力に関するもので、この workflow は真偽値の入力を渡していないため影響しません。
ubuntu-latest の runner は、Node.js 24 版の action が必要とするバージョンをすでに満たしていたので、runner 側の変更は必要ありませんでした。バージョンを上げたあとのデプロイも、これまでどおり成功しています。
まとめ
自動デプロイは一度作ると動き続けるので、手を入れるきっかけを見落としがちです。このサイトでは、次の3つのタイミングで workflow を見直しました。
- サイトの機能が特定の環境に依存するようになったとき、デプロイ先を一本化して管理コストを減らした
- 除外リストへの追記や直し忘れが起きたとき、公開するものだけを列挙する方式に切り替えた
- action のランタイムが非推奨になったとき、破壊的変更の影響を確認してからメジャーバージョンを上げた
同じように静的サイトを自動デプロイしている方は、公開対象の選び方と、使っている action のバージョンから見直してみてください。