Loading...


什麼是 CI/CD

CI/CD 是兩個縮寫拼在一起:

  • CI(Continuous Integration,持續整合):每次 push 程式碼,就自動跑 lint、測試、build。目的是「壞掉的程式碼在合併前就被抓到」,而不是靠人記得跑測試。
  • CD(Continuous Delivery / Deployment,持續交付 / 部署):測試通過後,自動把產出物(artifact)送到正式環境。Delivery 通常還留一個人工按鈕;Deployment 則是全自動上線。

把兩者串起來就是一條 pipeline:從 commit 到上線之間的每一步都寫成程式碼、由機器執行、每次結果一致。

好處很直接:

  1. 可重複:流程寫在 YAML 裡,不會因為誰忘了哪一步而出事。
  2. 快速回饋:push 幾分鐘後就知道 build 有沒有壞。
  3. 可回滾:每次上線都對應一個不可變的 image,出事直接切回上一版。
  4. 伺服器輕鬆:編譯在 CI 上做,VPS 只負責跑。

以前的流程:手動五步

這個 blog 跑在一台 VPS 上,用 Docker Compose 管三個容器:Next.js、nginx、certbot。上線流程長這樣:

  1. 本機跑 npm run build 確認會過。
  2. git push
  3. SSH 進 VPS。
  4. git pull
  5. docker compose up -d --build

痛點也很明確:

  • VPS 要自己編譯next build 會吃掉一台小 VPS 的大部分 CPU 與記憶體,build 的當下網站會變慢。
  • 沒有測試閘門:忘記跑測試也照樣能上線。
  • 沒有回滾:build 壞了就是壞了,要修好再重新 build 一次。
  • 靠人記步驟:五步裡漏一步,網站就停在舊版或半新不舊。

大公司怎麼做

規模再大的團隊,骨架其實都一樣,只是每一格換成更重的工具:

階段常見工具概念
CIGitHub Actions、GitLab CI、Jenkins、BuildkitePR 觸發 lint / test / build
Artifact容器 registry(GHCR、ECR、Harbor)image 用 commit SHA 當 tag,不可變
CDKubernetes + ArgoCD / Flux(GitOps)Git 裡改 manifest 的 tag,叢集自動同步
環境dev → staging → productionproduction 常需人工 approve
發布策略blue-green、canary失敗自動回滾
觀測Prometheus / Grafana、Sentry健康檢查決定發布成敗

單機 VPS 上 Kubernetes 太重,但上面的原則可以原封不動搬過來:image 不可變、用 SHA 當 tag、健康檢查把關、失敗就回滾。我們做的就是這套架構的縮小版,把 CD 那格從 ArgoCD 換成「SSH 進 VPS 跑 docker compose」。


我們的流程

整條 pipeline 放在一個 workflow 檔裡,四個 job 依序執行:

push main ──► test ──► build ──► deploy ──► promote
              lint     docker    SSH→VPS    :latest
              vitest   push      pull/up    只在成功後
                       :sha-xxx  healthcheck
                                 rollback
  • PR 或 push 到 dev:只跑 testbuild(build 只驗證 Dockerfile 能過,不 push)。
  • push 到 main:四段全跑,真正上線。
  • 手動觸發並指定 tag:跳過 test / build,直接部署既有的 image。這就是回滾按鈕。

1. test:lint 與單元測試

Loading...

沒什麼特別的,重點是它擋在 build 前面。測試沒過,後面全部不會跑。

2. build:在 CI 上建 image,推到 GHCR

Loading...

幾個設計點:

  • tag 用 commit SHA 前七碼sha-abc1234)。同一個 tag 永遠對應同一份程式碼,不會被覆蓋。
  • GHCR(GitHub Container Registry):跟 repo 同一個帳號,GITHUB_TOKEN 直接能登入,不用另外申請金鑰。
  • cache-from / cache-to: type=gha:把 Docker layer cache 存在 GitHub Actions cache 裡。只改了程式碼沒動 package.json 時,npm ci 那層直接命中,build 時間大幅縮短。
  • build-args:把 NEXT_PUBLIC_* 塞進去。為什麼需要這步,下一節會講。

3. deploy:SSH 進 VPS,pull 新 image 並重啟

Loading...

遠端 script 做的事,一步一步:

  1. git fetch + git merge --ff-onlydocker-compose.yml 與 nginx 的設定檔是從 VPS 上的 git checkout 掛載進容器的,所以還是要拉最新。fetch 走 HTTPS 並帶 job 的 GITHUB_TOKEN,VPS 上的部署帳號不需要 GitHub 的 SSH key。用 --ff-only 是為了 VPS 上若有人手動改過檔案,這裡會直接失敗而不是產生 merge commit。
  2. 登入 GHCR:用的是 workflow 的 GITHUB_TOKEN。它只在這個 job 執行期間有效,VPS 上不需要長期存一把 PAT。
  3. docker compose pull:只拉 nextjs 的 image。
  4. docker compose up -d --wait:重建容器,並等 healthcheck 轉成 healthy。超時就視為部署失敗。
  5. nginx reload:nginx 的 proxy_pass http://nextjs:3000 在啟動時就把 nextjs 解析成容器 IP。容器重建後 IP 可能變,reload 一次讓它重新解析,是 graceful 的,不會斷線。
  6. smoke test:從外部打一次網站首頁,確認整條 Cloudflare → nginx → Next.js 路徑都通。
  7. 任何一步失敗就 rollback:拉 :latest 回來重啟。

rollback 是遠端 script 裡的一個 shell function:

Loading...

而 healthcheck 定義在 docker-compose.yml

Loading...

Alpine 版的 Node image 沒有 curl,直接用 Node 20 內建的 fetch 打首頁即可。

4. promote:部署成功後才把 :latest 指過去

Loading...

這是整條 pipeline 裡最值得講的一個決定。

一般直覺是 build 完就同時打 :sha:latest 兩個 tag。但這樣一來,如果新版部署失敗,:latest 已經指向壞掉的 image,回滾時沒有東西可以拉。

所以我們把 :latest 的語意改成「最後一版部署成功的 image」:只有 deploy job 綠燈之後,promote job 才用 imagetools create:latest 重新指向這次的 SHA tag。它不重新 build,只是改 registry 上的 manifest tag,幾秒鐘完成。

於是 docker-compose.yml 裡的 image: ghcr.io/your-name/your-blog:${IMAGE_TAG:-latest} 就有了一個安全的預設值:deploy 時帶 IMAGE_TAG 用新版;rollback 或有人手動在 VPS 上 docker compose up -d,拿到的永遠是上一版好的。

回滾

兩條路:

  • 自動:deploy script 內任何一步失敗,立刻拉 :latest 重啟。
  • 手動:在 GitHub Actions 頁面按 Run workflow,填入想回去的 tag(例如 sha-abc1234)。testbuild 會被跳過,直接部署那個 image,成功後它會被 promote 成新的 :latest

設計原理:五個原則

逐項解釋設定之前,先講整條 pipeline 背後的五個原則。後面每一個設定都能對應回其中一條。

  1. Artifact 不可變:一個 commit 對應一個 image,tag 是 SHA,建好之後不再改。部署與回滾都只是「換一個 tag」。
  2. Build-time 與 run-time 分離:烤進 bundle 的東西(NEXT_PUBLIC_*)在 CI 給;伺服器才需要的機密在 VPS 給。兩者不混。
  3. 健康檢查是閘門:容器沒 healthy、首頁打不通,就不算部署成功。成功與否由機器判定,不靠人看。
  4. 最小權限、短命憑證:CI 用 job 結束就失效的 GITHUB_TOKEN 做所有對 GitHub 的操作;VPS 上只有一把只能登入部署帳號的 SSH key。
  5. 預設安全(fail-closed):任何一步失敗,結果都是「網站維持舊版」。:latest 只在成功後移動,手動 docker compose up -d 也拿到好的版本。

Workflow 逐項解釋

頂層設定

欄位意義
namePipelineActions 頁面顯示的名稱
on.push.branches[main, dev]推這兩個分支才觸發。其他分支不跑,省額度
on.pull_request.branches[main, dev]目標是這兩個分支的 PR 觸發。PR 只跑 test + build
on.workflow_dispatch.inputs.image_tagstring,預設空手動觸發時的輸入欄。填了就是回滾模式
permissionscontents: readpackages: write收窄 GITHUB_TOKEN:能讀 repo、能推 GHCR,其他都不行
env.IMAGE_NAMEghcr.io/<owner>/<repo>全 workflow 共用的常數。GHCR 要求全小寫
env.NODE_VERSION20與 Dockerfile 的 node:20-alpine 對齊
concurrency.grouppipeline-${{ github.ref }}同一分支同時只跑一個 run
concurrency.cancel-in-progressgithub.ref != 'refs/heads/main'非 main 的舊 run 被新 push 取消;main 不取消,排隊

Job 層級

欄位意義
needs依賴。build needs testdeploy needs [test, build]。上游失敗,下游預設被 skip
if這個 job 要不要跑。上游 skip 時,needs.x.resultskipped不是 success,寫條件時要分清楚
runs-on: ubuntu-latestGitHub 提供的臨時 VM,跑完銷毀
environment.name: production標記這是正式環境部署。Actions 頁會記錄歷史,之後可加 required reviewers 變人工 approve
environment.url部署完成後顯示的連結
outputs給下游 job 讀的字串,例如 deploy 算出的 tag 給 promote
job 層 env該 job 所有 step 都看得到的變數。test job 用它把 NEXT_PUBLIC_*next lint
job 層 concurrencydeploy 另外掛 production-deploycancel-in-progress: false,確保部署不會被砍

deploy 的 if 條件

Loading...
  • !cancelled():預設的 success() 會在任何上游被 skip 時讓 job 不跑,但「dispatch 帶 tag」本來就要跳過 test/build,所以改用這個。
  • 第一條路:build 成功,而且事件是 push main 或手動觸發。PR 的 build 也會成功,但事件不對,不部署。
  • 第二條路:手動、有填 tag、test 和 build 都是被自己的 if 跳過。三個條件缺一不可,否則「test 失敗導致 build 被 skip」也會被誤判為回滾模式。這是實際踩到的坑,後面會講。

Step 與 action 參數

Step / action參數意義
actions/checkout@v4把 repo 拉進 runner。build 需要完整 context
actions/setup-node@v4node-versioncache: npm裝 Node;cache: npmpackage-lock.json hash 快取 ~/.npmnpm ci 變快
run: npm i -g [email protected]runner 內建 npm 10,與本機 / Dockerfile 的 npm 11 對 lock 檔的判定不同
docker/setup-buildx-action@v3啟用 BuildKit builder,才有 cache-from/to 與多平台
docker/login-action@v3registryusername: github.actorpassword: GITHUB_TOKEN登入 GHCR。只在要 push 的 run 執行
docker/build-push-action@v6context: .build context 是 repo 根目錄
platforms: linux/amd64VPS 是 x86。runner 也是 x86 所以不用 QEMU
push只有 push main 為 true;PR 只 build 驗證
tagsIMAGE_NAME:sha-xxxxxxx
build-args對應 Dockerfile 的 ARG,值來自 vars.*
cache-from: type=gha / cache-to: type=gha,mode=maxlayer cache 存在 Actions cache。mode=max 連中間 stage 也存
appleboy/ssh-action@v1hostportusernamekeySSH 連線資訊,全部來自 Secrets
envs白名單:哪些 runner 環境變數要 export 進遠端 shell
script在 VPS 上執行的 shell 內容
docker buildx imagetools create--tag :latest :sha-xxx只改 registry 上的 manifest tag,不下載也不重建

用到的表達式與 context

寫法意義
${{ github.sha }} / ${GITHUB_SHA::7}觸發的 commit。shell 裡取前七碼當 tag
${{ github.ref }}refs/heads/main 這種完整 ref
${{ github.event_name }}pushpull_requestworkflow_dispatch
${{ github.actor }}觸發的人,拿來當 GHCR 登入帳號
${{ github.repository }}owner/repo,遠端 fetch 用
${{ inputs.image_tag }}dispatch 輸入;非 dispatch 事件時是空字串
${{ secrets.X }} / ${{ vars.X }}機密 / 明文設定
${{ needs.build.result }}success / failure / skipped / cancelled
${{ steps.tag.outputs.tag }}同 job 內某 step 用 echo "tag=..." >> "$GITHUB_OUTPUT" 寫出的值
secrets.VPS_PORT || 22表達式的 ||:空字串視為 false,退回 22

Docker 與 Compose 設定的意義

Dockerfile

Loading...
  • 三個 stage:deps 層只在 package*.json 變動時重建;builder 每次跑;runner 只 COPY 產物,不含 devDependencies 與原始碼。
  • ARG 而不是 ENVARG 只存在於 build 期,而且沒傳時是「未設定」。若寫成 ENV X=${X},沒傳會變成空字串,把 .env.production 的值蓋掉。
  • USER nextjs:非 root 執行。

docker-compose.yml 的 nextjs

欄位意義
image: ghcr.io/…:${IMAGE_TAG:-latest}從 registry 拉。${VAR:-default} 是 compose 的插值語法,沒給就 latest
build: .保留本機 docker compose up --build 的路徑。同時有 imagebuild 時,pull 拉 image,--build 才會本機建
env_file: ${ENV_FILE:-.env.production}run-time 變數來源,不進 image
healthcheck.test容器內執行的指令,exit 0 = 健康
healthcheck.interval: 30s每 30 秒測一次
healthcheck.timeout: 10s單次超過 10 秒算失敗
healthcheck.retries: 3連續 3 次失敗才標 unhealthy
healthcheck.start_period: 20s啟動後 20 秒內的失敗不計,給 Next.js 暖機
restart: alwaysdaemon 重啟或容器掛掉自動拉起

docker compose up -d --wait --wait-timeout 180-d 背景、--wait 等到所有服務 running 或 healthy 才返回、--wait-timeout 最多等 180 秒,超時回非零 exit。這就是把 healthcheck 變成部署閘門的關鍵。

.dockerignore

COPY . . 會把整個 context 送進 builder。排除 certbot/(憑證私鑰)、.git.next、tests、docs、.github 之後,context 小、build 快,私鑰也不會留在 layer 裡。


遠端 script 逐行

指令意義
set -eu任一指令失敗立即退出;用到未定義變數也退出。不用 pipefail,因為遠端可能是 dash
trap '… docker logout …' EXIT不論怎麼結束都登出 GHCR,token 不留在 VPS
git fetch "https://x-access-token:${GH_TOKEN}@github.com/${GH_REPO}.git" "+main:refs/remotes/origin/main"用 job token 走 HTTPS 抓 main,寫進 origin/main 這個追蹤 ref。VPS 不需要 GitHub SSH key
git checkout -q main確保在 main 上
git merge --ff-only origin/main只允許快轉。VPS 上有人手動改過就會失敗,寧可停也不要產生 merge commit
printf '%s' "$GH_TOKEN" | docker login … --password-stdin從 stdin 讀密碼,不出現在 process list
rollback() { … }定義在會失敗的步驟之前。內容:印 log、拉 :latest、重啟、reload nginx、exit 1
IMAGE_TAG=… docker compose pull nextjs只拉 nextjs 的新 image。拉失敗直接退出,此時什麼都還沒改
IMAGE_TAG=… docker compose up -d --wait … || rollback重建容器並等 healthy;超時就回滾
docker compose exec -T nginx nginx -s reloadnginx 啟動時就把 nextjs 解析成 IP,容器重建後可能變,reload 重新解析。-T 是不配 tty
curl --fail --retry 6 --retry-delay 5 --retry-all-errors "$SITE_URL/" || rollback從外部打首頁,最多重試 6 次、每次隔 5 秒。任何錯誤都重試
docker image prune -f清掉沒被任何容器用的舊 image,磁碟不會被 sha tag 塞滿

要注意的點

1. NEXT_PUBLIC_* 是 build 期烤進去的

這是 Next.js 專案改 CI/CD 最容易踩的坑。NEXT_PUBLIC_ 開頭的環境變數會在 next build 時被寫死進 client bundle。以前在 VPS 上 build,Next.js 讀的是 VPS 上的 .env.production;搬到 CI 之後,那個檔案不在 git 裡,CI 根本看不到它。

解法是 Dockerfile 的 builder stage 宣告 ARG,由 CI 透過 build-args 傳入:

Loading...

ARG 沒有給預設值時,在 RUN 裡是「未設定」而不是空字串,所以本機直接 docker compose up --build 的行為和以前一樣,Next.js 照舊讀 context 裡的 .env.production

延伸的注意事項:CI 那邊的值必須跟 VPS 上 .env.production 的值一致。Server Component 在 runtime 讀的是 VPS 上的 env,client bundle 用的是 CI 烤進去的值,兩邊不一樣會出現很難追的 hydration 差異。

2. 分清楚 build-time 與 run-time 的變數

種類例子要放在
build-timeNEXT_PUBLIC_*CI 的 build-args
run-time(server 端)R2 access key、admin 開關VPS 上的 .env.production,由 compose 的 env_file 注入

Secret 永遠不該進 build-args。它會留在 image 的 build history 裡,任何能 pull 這個 image 的人都看得到。

3. Secrets 與 Variables 分開放

GitHub 提供兩種倉庫層級的設定:

  • Secrets:加密儲存,log 裡會自動打碼。放 SSH 私鑰、主機位址、伺服器上的路徑。
  • Variables:明文儲存,適合本來就不是機密的設定。NEXT_PUBLIC_* 反正會被烤進公開的 client bundle,放這裡就好。

分開放的好處是 Variables 能在 workflow log 裡看見,debug 方便;而 Secrets 就算不小心 echo 出來也會是 ***

4. SSH 用專用帳號,不用 root

在 VPS 上建一個只做部署的使用者,加進 docker 群組,authorized_keys 只放 CI 用的那把 key。repo 目錄的擁有者也設成這個使用者,不然 git merge 會因為權限失敗。防火牆若允許,SSH 只開給 GitHub Actions 的 IP 範圍,或改走 Tailscale / Cloudflare Tunnel。

5. 部署要排隊,不能被取消

Loading...

連續 push 兩次 main 時,第二次要等第一次部署完。若允許取消,很可能第一次 docker compose pull 拉到一半被砍掉,留下一個半套的狀態。反過來,PR 的 CI 就適合 cancel-in-progress: true,新 push 直接把舊的 run 作廢省資源。

6. .dockerignore 不只是縮小 context

Dockerfile 裡的 COPY . . 會把 build context 全部複製進 builder stage。以前在 VPS 上 build,certbot/ 目錄裡的憑證私鑰就這樣被 COPY 進了 image layer。雖然最終的 runner stage 沒有它,但 builder layer 還留在 VPS 的 Docker cache 裡。

certbot.git__tests__cypressdocs.github 全部列進 .dockerignore,既縮小 context、加快上傳,也堵住這個洞。

7. 手動觸發的輸入要當成不可信

workflow_dispatchinputs.image_tag 會被塞進遠端 shell script。雖然只有 repo 協作者能觸發,還是先用白名單 regex 檢查一次:

Loading...

8. Docker Compose 版本

docker compose up --wait --wait-timeout 需要 Compose v2.17 以上。VPS 上先跑 docker compose version 確認。

9. 不是零停機

docker compose up -d 重建 nextjs 容器時,舊容器停止到新容器 healthy 之間會有幾秒鐘 502。個人 blog 可以接受;真要零停機得做 blue-green,也就是同時起兩個容器再切 nginx upstream。這是之後的題目。


能傳入的參數種類

pipeline 裡的「參數」其實來自很多層,每一層的生命週期、可見範圍、安全等級都不同。整理成一張表:

種類定義位置讀取方式適合放什麼備註
Secretsrepo Settings → Secrets${{ secrets.NAME }}SSH 私鑰、主機、路徑log 自動打碼;fork 的 PR 拿不到
Variablesrepo Settings → Variables${{ vars.NAME }}非機密設定,如 NEXT_PUBLIC_*明文,log 看得到
Environment 層級的 Secrets / Varsrepo Settings → Environments同上,但只在宣告 environment: 的 job 內生效區分 staging / production 的值可設定人工 approve、限制分支
GITHUB_TOKEN自動產生${{ secrets.GITHUB_TOKEN }}登入 GHCR、呼叫 GitHub API每個 job 一把,job 結束即失效;權限由 permissions: 控制
workflow_dispatch inputsworkflow 檔的 on.workflow_dispatch.inputs${{ inputs.NAME }}手動觸發時的參數,如要回滾的 tag使用者輸入,要驗證
env:workflow / job / step 層級${{ env.NAME }} 或 shell 的 $NAME常數,如 image 名稱、Node 版本純設定,不是機密
ContextsGitHub 自動提供${{ github.sha }}${{ github.ref }}${{ github.actor }}算 tag、判斷分支、登入帳號唯讀
Job outputsjob 的 outputs: + step 的 $GITHUB_OUTPUT${{ needs.job.outputs.NAME }}job 之間傳值,如 deploy 算出的 tag 給 promote 用字串
Docker build-argsdocker/build-push-actionbuild-args:Dockerfile 的 ARGbuild 期需要的公開設定會留在 image history,不放 secret
Docker build secretsdocker/build-push-actionsecrets:Dockerfile 的 RUN --mount=type=secretbuild 期需要的機密,如私有套件 token不會留在 layer
Runtime envVPS 上的 .env.productioncompose 的 env_file: / environment:server 端執行時需要的機密與設定不進 git、不進 image
Compose 變數插值執行 compose 的 shell 環境或 .env${IMAGE_TAG:-latest}切換 image tag、port、domain有預設值語法
ssh-action 的 envs:step 的 env: + envs: 白名單遠端 shell 的 $NAME把 CI 的變數送進 VPS script沒列在 envs: 的不會傳過去

用一句話記:機密放 Secrets 或 VPS runtime env;公開設定放 Variables;build 期要的用 build-args;人工參數走 inputs;job 之間用 outputs。


實戰踩坑紀錄

上面的設定不是一次寫對的。第一次 push 到 main 之後紅了三次才綠,每一次都值得記下來。

1. npm ci 說 lock 檔不同步

npm error `npm ci` can only install packages when your package.json and package-lock.json are in sync.
npm error Missing: @swc/[email protected] from lock file

本機 npm ci 明明沒事。差別在 npm 版本:runner 的 Node 20 內建 npm 10,本機和 Dockerfile 用 npm 11。lock 裡 next-intl 底下有一份巢狀的 @swc/core,宣告了 optional peer @swc/helpers >=0.5.17;npm 11 認為 optional 不必滿足,npm 10 認為缺少。lock 沒錯、程式沒錯,是工具版本不一致。

修法:test job 在 npm ci 前先 npm i -g [email protected],與 Dockerfile 同版。教訓是 CI 的工具鏈要和 build image 對齊,否則同一份 lock 會有兩種解讀。

2. Host key verification failed

第二次 run,SSH 進 VPS 成功了,第一個指令就掛:

==> sync repo
Host key verification failed.
fatal: Could not read from remote repository.

VPS 上的 origin[email protected]: 的 SSH remote,以前用 root 操作,root 有 GitHub key 和 known_hosts。新的 deploy 帳號兩樣都沒有。

可以幫 deploy 產一把 key 加成 repo 的 deploy key,但那又是一把長期存在 VPS 上的憑證。改成用 job 的 GITHUB_TOKEN 走 HTTPS:

Loading...

+main:refs/remotes/origin/main 這個 refspec 把抓到的 main 寫進 origin/main,後面的 merge --ff-only origin/main 一行都不用改。VPS 上的 origin remote 保留給人手動用。

3. test 失敗,deploy 卻跑了

第三次 run 更詭異:lint 紅燈,Deploy to VPS 居然執行,然後在 docker compose pullmanifest unknown。因為 image 根本沒 build。

原因在 deploy 的 if。原本寫 needs.build.result == 'success' || needs.build.result == 'skipped',想涵蓋「手動帶 tag 時 build 被跳過」。但 test 失敗時 build 也是 skipped,條件一樣成立。

修法是把「回滾模式」寫成完整的三個條件:事件是 dispatch、有填 tag、test 與 build 都 skipped。教訓:skipped 不等於 success,任何用到 needs.*.result 的條件都要把「上游失敗導致的 skip」考慮進去。幸好 script 在改動任何東西之前就因 pull 失敗退出,VPS 沒事。

4. next lintnext.config.js 炸掉

Invalid next.config.js options detected:
  "images.remotePatterns[0].hostname" is missing, expected string

next.config.jsremotePatterns 直接用 process.env.NEXT_PUBLIC_R2_PUBLIC_URL。本機有 .env 檔所以永遠有值;CI 沒有,next lint 載入 config 時驗證失敗。順帶一提,這也代表任何人 fresh clone 之後不建 .env 就跑不了 lint。

修法:加 fallback || 'media.example.com',與程式碼裡其他地方的寫法一致;同時 test job 也帶入 NEXT_PUBLIC_* Variables。教訓:CI 沒有 .env,config 與 build 期讀 env 的地方都要有 fallback 或明確傳值。

5. VPS 少了一個變數

對照 GitHub Variables 時發現 VPS 的 .env.production 沒有 NEXT_PUBLIC_SITE_URL。網站沒壞,因為程式碼有 fallback。但這正是「client bundle 用 CI 的值、server 用 VPS 的值」會分岔的地方。補上,兩邊一致。

6. 目錄搬家與 compose project name

repo 原本在 /root 底下,deploy 帳號進不去。搬到 /home/deploy/<repo> 時要注意:Compose 用目錄名當 project name,目錄名不變就還是同一組容器;搬完跑一次 docker compose up -d 讓 bind mount 指到新路徑。

7. 小事

  • 文章沒 cover 時列表頁會破圖:cover 是必填。
  • docs/.gitignore 裡但部分檔案已被追蹤,新檔要 git add -f
  • gh auth login 是互動式,塞進非互動 session 會卡住。

三次紅燈之後的第四次 run 全綠。之後每次 push 都是幾分鐘內自動上線。


結語

改完之後的日常只剩一件事:git push。幾分鐘後 Actions 頁面綠燈,網站就是新版;紅燈的話網站還是舊版,什麼都不用做。

還沒做、之後想補的:

  • staging 環境:用 GitHub Environments 分出一套 staging,main 先進 staging,approve 後才進 production。
  • E2E 進 pipeline:Cypress 現在只在本機跑,可以在 build 後起一個容器跑一輪。
  • 零停機:blue-green 或至少讓 nginx 用 resolver 動態解析 upstream。
  • 通知:部署成功 / 失敗發訊息到 Discord 或 Slack。

流程一旦寫成程式碼,這些都只是往 YAML 裡再加一個 job 的事。