Day28 幫 S3+CloudFront 這條路接上了自己的 Domain,不過每次改內容,還是要自己手動 build、aws s3 sync、再清 CloudFront 的 cache。所以今天先把 cache 的規則設好,再把這些步驟交給 GitHub Actions,push 到 main 之後就自動部署ㄅ!
先看看現在的網站,回了哪些跟 cache 有關的 header:
DOMAIN=static.example.com
curl -sI "https://$DOMAIN/" | grep -iE '^(cache-control|last-modified|age|x-cache)'
結果會出現類似:
last-modified: Sun, 11 Oct 2026 13:14:42 GMT
x-cache: Hit from cloudfront
age: 29905
裡面沒有 cache-control。S3 只是放檔案的倉庫,上傳時沒有幫檔案設定,回應時就不會帶。沒有 cache-control 的時候,兩邊的 cache 是這樣決定的:
| 誰的 cache | 沒有 cache-control 的時候 |
|---|---|
| CloudFront | 照 Day27 選的 CachingOptimized,預設放 24 小時。age 是這份 cache 已經放了幾秒,29905 秒大約是 8 小時 |
| 瀏覽器 | 自己猜(heuristic caching)。常見的做法是拿「距離 last-modified 過了多久」的 10%,例如 10 天前上傳的檔案,瀏覽器可能一整天都不回來問 |
如果看到的是
x-cache: RefreshHit from cloudfront、也沒有age的話,表示 CloudFront 手上那份已經放滿 24 小時而過期了。所以它會先回去問 S3 檔案有沒有變;沒變的話就繼續用手上那份,並且重新計時,也因此不會有age。再執行一次,就會看到Hit from cloudfront和一個很小的age。
CloudFront 的 cache,我們可以用 invalidation 清掉;但每個使用者瀏覽器裡的 cache,我們清不到。已經被瀏覽器猜著存起來的舊檔案,只能等它自己過期,所以 cache-control 最好在網站一上線就設好喔!
那 EC2 那條路為什麼沒遇到這個問題?拿 EC2 上的網站來對照,看看首頁和 _next/static 底下的一個 JS 檔:
curl -sI https://example.com/ | grep -i '^cache-control'
JS=$(curl -s https://example.com/ | grep -oE '/_next/static/[^"]+\.js' | head -1)
curl -sI "https://example.com$JS" | grep -i '^cache-control'
會看到類似:
cache-control: s-maxage=31536000
cache-control: public, max-age=31536000, immutable
這兩行是 EC2 上的 Next.js Server 自己加的,它會依照檔案的種類送不同的 Cache-Control。換到 S3 之後沒有 Server 了,這件事就要我們在上傳的時候自己做。
out 裡的檔案,可以照「網址會不會變」分成兩種:
| 網址會不會變 | 例子 | |
|---|---|---|
_next/static 底下的 JS、CSS、字型 |
會。內容一改,檔名裡的 hash 就跟著變 | _next/static/chunks/0a1b2c3d4e5f6789.js |
| 其他檔案 | 不會,永遠是同一個網址 | index.html、index.txt、404.html、favicon.ico |
檔名會變的檔案,舊網址永遠是舊內容,新內容會換一個新網址,所以可以放心 cache 很久。網址不變的檔案就不一樣了,改版之後一定要讓大家拿到新版。剛剛也有提到,CloudFront 的 cache 是我們清得到,但使用者瀏覽器的我們不行了,所以訂下的規則是這樣:
| 檔案 | Cache-Control | 瀏覽器 | CloudFront |
|---|---|---|---|
_next/static 底下 |
public, max-age=31536000, immutable |
一年內直接用 | 一年 |
| 其他檔案 | public, max-age=0, s-maxage=31536000 |
每次用之前先問 | 一年,每次部署時清掉 |
每個值的意思:
| 寫法 | 意思 |
|---|---|
public |
瀏覽器和 CloudFront 都可以把它存起來 |
max-age=31536000 |
存起來之後,31,536,000 秒(一年)內直接用,不用回來問 |
immutable |
這個檔案不會變,使用者按重新整理也不用回來問 |
max-age=0 |
存起來之後馬上就算過期,下次要用之前,要先問一次有沒有新版 |
s-maxage=31536000 |
只給 CloudFront 這種很多人共用的 cache 看:放一年。CloudFront 看到 s-maxage 就照它,不看 max-age;瀏覽器則只看 max-age |
_next/static 的設定跟 EC2 上的 Next.js 一模一樣。HTML 也跟 Next.js 一樣用 s-maxage 讓 CloudFront 放一年,那為什麼多了一個 max-age=0?因為 S3 的回應一定會帶 last-modified,如果沒寫 max-age 的話,瀏覽器就會拿 last-modified 來猜要存多久。
「每次都先問」其實很輕:瀏覽器會帶著上次拿到的 ETag 去問,內容沒變的話,CloudFront 直接從最近的據點回 304 Not Modified,不用再傳一次整個檔案。可以自己試試看:
ETAG=$(curl -sI "https://$DOMAIN/" | grep -i '^etag:' | cut -d' ' -f2 | tr -d '\r')
curl -sI -H "If-None-Match: $ETAG" "https://$DOMAIN/" | grep -iE '^(HTTP|x-cache)'
應該會看到類似結果:
HTTP/2 304
x-cache: Hit from cloudfront
CloudFront 這邊,Day27 選的 CachingOptimized 允許的 cache 時間是最少 1 秒、最多一年。檔案有寫 s-maxage 或 max-age 時,就照檔案寫的時間;都沒寫,才用預設的 24 小時。
兩種檔案要設定不同的 Cache-Control,所以 aws s3 sync 要分兩次執行,而且要先傳 _next/static:
BUCKET=my-app-static-20261010
# 1. 先傳 _next/static, cache 一年
aws s3 sync out/_next/static "s3://$BUCKET/_next/static" \
--cache-control "public, max-age=31536000, immutable"
# 2. 再傳其他檔案,瀏覽器每次都要先問
aws s3 sync out/ "s3://$BUCKET/" \
--exclude "_next/static/*" \
--cache-control "public, max-age=0, s-maxage=31536000" \
--delete
--cache-control:上傳時把 Cache-Control 存進檔案的設定(metadata),之後 S3 回應這個檔案時都會帶上它。_next/static:新的 index.html 會指向新檔名的 JS 和 CSS。如果先傳 HTML,剛好在這幾秒打開網站的人,會拿到新的 HTML、卻找不到新的 JS 和 CSS,畫面就壞了。--exclude "_next/static/*":第二次跳過 _next/static,剛剛已經傳過了。--delete:把 bucket 裡有、但 out 裡已經沒有的檔案刪掉,例如被刪掉的頁面。被 --exclude 排除的檔案不會被刪,所以舊版的 _next/static 會留著。已經開著舊頁面的人,之後還可能去讀舊的 JS,留著才不會壞掉。這些檔案都很小,想清的話再找時間手動清就好。如果是在自己的電腦上執行,記得跟 Day27 一樣加上 --region。另外,aws s3 sync 只會上傳「大小不一樣」或「本機比較新」的檔案,沒有重新 build 就再 sync 一次的話,檔案沒變,Cache-Control 也不會被更新。等一下交給 GitHub Actions 之後,每次都是重新 build,所有檔案也都會重新上傳,就不會遇到這個問題。
傳完再清 CloudFront 的 cache,就是 Day27 用過的這個指令:
aws cloudfront create-invalidation --distribution-id E1A2B3C4D5E6F7 --paths "/*"
/* 記得加引號,不然終端機會先把它展開成檔案名稱。每個 AWS 帳號每個月前 1,000 個 invalidation 路徑免費,/* 不管清掉多少檔案都只算 1 個,一天部署好幾次也用不完。
Day25 的 gha-deploy-role 只有 ECR 和 SSM 的權限,今天幫它多加一份 S3 和 CloudFront 的。
到 IAM 的 Console:
gha-deploy-role。123456789012、E1A2B3C4D5E6F7 換成自己的。static-deploy-permissions,按 Create policy(建立政策)。{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListStaticBucket",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::my-app-static-20261010"
},
{
"Sid": "UploadAndDeleteFiles",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::my-app-static-20261010/*"
},
{
"Sid": "ClearCloudFrontCache",
"Effect": "Allow",
"Action": [
"cloudfront:CreateInvalidation",
"cloudfront:GetInvalidation"
],
"Resource": "arn:aws:cloudfront::123456789012:distribution/E1A2B3C4D5E6F7"
}
]
}
逐段來看:
aws s3 sync 會先列出 bucket 裡已經有哪些檔案,才知道哪些要傳、哪些要刪。s3:ListBucket 是對 bucket 本身,所以 Resource 結尾沒有 /*。/*。這兩段的 Resource 很容易寫反,寫反就會看到 AccessDenied。這裡沒有給 s3:GetObject,因為上傳不需要讀 S3 上的檔案內容。
練習專案的兩條路都是從 main 部署,所以共用 Day24 那個只信任 main 的 Role 就好。但像是我的 side project 不一樣:正式站從 main 部署,S3 的展示版則是從另一個 branch 部署,所以我另外開了一個只信任那個 branch、只有上面這三段權限的 Role,正式站那個 Role 的信任設定一個字都不用改。
今天的 workflow 會拆成 build 和 deploy 兩個 job,原因跟 npm ci 有關。npm ci 會裝上百個套件,有些套件在安裝或 build 的時候會執行自己的程式。但 workflow 只要給了 id-token: write,同一個 job 裡的每個步驟,都可以拿得到跟 GitHub 要 OIDC token 用的網址和憑證(ACTIONS_ID_TOKEN_REQUEST_URL、ACTIONS_ID_TOKEN_REQUEST_TOKEN 這兩個環境變數)。也就是說,套件裡的程式也能自己去換 gha-deploy-role 的身分。這不是想太多:2025 年 11 月的 Shai-Hulud 2.0 事件,就是被植入惡意程式的 npm 套件在安裝時執行,偷走 CI 環境裡的 token 和雲端憑證。
Day25 的 npm ci 是在 docker build 裡面跑的,碰不到 Runner 上的環境變數;今天則是直接改在 Runner 上 build,所以把它們拆開:
| job | 做什麼 | 有沒有 id-token: write |
|---|---|---|
| build | npm ci、npm run build,把 out 打包交給下一個 job |
沒有,換不到 AWS 的身分 |
| deploy | 拿到 out、換 AWS 身分、上傳 S3、清 cache |
有,但不執行任何套件的程式,只跑 AWS CLI |
在練習專案中新增 .github/workflows/deploy-static.yml,env 底下的四個值換成自己的:
name: Deploy static site
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-static
cancel-in-progress: false
env:
AWS_REGION: ap-east-2
S3_BUCKET: my-app-static-20261010
CLOUDFRONT_DISTRIBUTION_ID: E1A2B3C4D5E6F7
SITE_URL: https://static.example.com
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build static site
run: npm run build
env:
STATIC_EXPORT: "true"
- name: Upload build output
uses: actions/upload-artifact@v7
with:
name: site
path: out/
if-no-files-found: error
retention-days: 1
deploy:
needs: build
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- name: Download build output
uses: actions/download-artifact@v8
with:
name: site
path: out
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Upload to S3
run: |
aws s3 sync out/_next/static "s3://$S3_BUCKET/_next/static" \
--cache-control "public, max-age=31536000, immutable"
aws s3 sync out/ "s3://$S3_BUCKET/" \
--exclude "_next/static/*" \
--cache-control "public, max-age=0, s-maxage=31536000" \
--delete
- name: Invalidate CloudFront cache
run: |
INVALIDATION_ID=$(aws cloudfront create-invalidation \
--distribution-id "$CLOUDFRONT_DISTRIBUTION_ID" \
--paths "/*" \
--query Invalidation.Id --output text)
echo "Invalidation ID: $INVALIDATION_ID"
aws cloudfront wait invalidation-completed \
--distribution-id "$CLOUDFRONT_DISTRIBUTION_ID" \
--id "$INVALIDATION_ID"
- name: Smoke test
run: |
curl --silent --show-error --head "$SITE_URL/" | grep -iE '^(HTTP|cache-control|x-cache)'
curl --fail --silent --show-error --output served-index.html "$SITE_URL/"
cmp served-index.html out/index.html
on、concurrency、env、Configure AWS credentials 這幾段,跟 Day25 的 deploy.yml 一樣,就不重複了。來看不一樣的地方:
permissions:最上面只給 contents: read,讓 build job 可以 checkout。deploy job 自己寫了 permissions,這個 job 就照自己的設定,只有 id-token: write。它不用 checkout,所以不需要 contents: read。concurrency:group 跟 Day25 的 deploy 不一樣。兩個 workflow 可以同時跑,但同一個 workflow 不會有兩次部署同時進行。node:22-alpine 一樣。cache: npm 會把下載過的套件存起來,下次 npm ci 比較快。STATIC_EXPORT=true,build 出 out 資料夾。out 上傳成 artifact,deploy 再下載回來。retention-days: 1 是讓 GitHub 一天後刪掉它,真正的網站已經在 S3 上了。needs: build:deploy 要等 build 成功才開始,build 失敗就不會部署。aws s3 sync。region 在 Configure AWS credentials 設好了,不用再加 --region。wait 等它真的清完。cmp 跟這次 build 出來的 index.html 比對。兩個檔案一模一樣時,cmp 不會印出任何東西;不一樣的話,會印出第一個不同的位置,這一步就會失敗。為了看得出有沒有更新,把 Day25 在首頁加的那行字改一下:
<p>Deployed by GitHub Actions: Day29</p>
git add .github/workflows/deploy-static.yml app/page.tsx
git commit -m "ci: push 到 main 自動部署到 S3+CloudFront"
git push origin main
這次 push 會同時觸發兩個 workflow:Day25 的 Deploy to EC2,和今天的 Deploy static site。點進 Deploy static site,可以看到 build 和 deploy 兩個 job 連成一條線:
npm ci 和 Next.js build 的 logs,最後是上傳 artifact。upload: 都是一個上傳的檔案。第一次 sync 只有 _next/static,第二次是其他檔案;如果有被刪掉的頁面,會看到 delete:。HTTP/2 200 和新的 cache-control。cmp 沒有輸出,就代表線上的首頁就是這次 build 的版本。全部綠燈之後,用瀏覽器打開 https://static.example.com,就會看到剛剛改的字了。EC2 那邊的網站,也會在 Deploy to EC2 跑完之後更新。push 一次,兩條路都換上新版!
最後在 Mac 上確認 Cache-Control 有設好:
DOMAIN=static.example.com
curl -sI "https://$DOMAIN/" | grep -iE '^(HTTP|cache-control|x-cache)'
JS=$(curl -s "https://$DOMAIN/" | grep -oE '/_next/static/[^"]+\.js' | head -1)
curl -sI "https://$DOMAIN$JS" | grep -iE '^(HTTP|cache-control|x-cache)'
會看到類似:
部署完的第一次可能會是 Miss from cloudfront,再執行一次就會變成 Hit。
也可以用 Chrome 的 DevTools 看:打開 Network Tab(Disable cache 不要勾),重新整理兩次。第二次時,首頁那一列的 Status 會是 304;_next/static 的 JS 和 CSS,Size 那一欄會顯示 (memory cache) 或 (disk cache),代表瀏覽器直接用了自己存的那一份,連問都沒問。
_next/static 會一直留著,但都很小,幾乎不用錢。到這裡,S3+CloudFront 這條路也做到 push 之後自動部署了!明天是最後一篇,來回顧這 30 天:比較 EC2 和 S3+CloudFront 兩條路、算一下總共花了多少錢,再畫一張完整的架構圖ㄅ!
資安小提醒:
id-token: write只給真的需要換 AWS 身分的 job,會執行套件程式的 job 不要給。