用 GitHub Actions 把 Node.js 專案自動部署到 VPS(PM2 實作)

還記得我剛轉職寫程式那陣子,每次改完功能要上線,都是手動 SSH 連進主機、git pull、npm install、再手動重啟服務⋯⋯一個流程跑下來十幾分鐘,還常常因為少打一個指令而出包。那種「我到底有沒有忘記哪一步」的焦慮感,我相信很多剛開始碰部署的朋友都懂。
這篇就要來解決這件事:用 GitHub Actions 把整個部署流程自動化,讓你「git push 之後喝口水,回來服務就已經更新好了」。
先講清楚定位:這篇用 PM2 來管理 Node.js 程序,目的是讓你先理解「自動部署到底在做哪些事」。實務上業界更常用 Docker 容器化來做,但那是另一個層次的故事。先把基礎觀念搞懂,之後學 Docker 會輕鬆很多。
整篇分成三段:環境的金鑰準備、撰寫 workflow、以及一些容易踩雷的補充。
先搞懂:自動部署的本質是什麼
在動手前,我想先帶大家建立一個畫面感。所謂的「自動部署」,其實沒有那麼神秘,它就是把你平常手動做的事,交給一台 GitHub 提供的臨時電腦(Runner)幫你跑一遍:
- 把最新的程式碼抓下來
- 安裝套件、做必要的建置
- 透過 SSH 連到你的主機,把檔案傳過去
- 重啟服務
關鍵在第 3 步:GitHub 的 Runner 是一台陌生的機器,它憑什麼能登入你的主機?答案就是 SSH 金鑰。我們要做的,就是發一把「鑰匙」給它,並告訴主機「拿這把鑰匙來的都是自己人」。理解了這層關係,後面的設定就不會只是照抄指令了。
第一步:準備 SSH 金鑰並登記到主機
這篇假設你的主機已經裝好 Node.js 與 PM2。如果還沒,建議先把環境跑起來再回來。
我們要做的事可以拆成兩半:產生一把鑰匙(公私鑰),然後把公鑰登記在主機、把私鑰交給 GitHub。
產生金鑰
在本機(或主機)執行,記得把 Email 換成自己的:
ssh-keygen -t rsa -b 4096 -C "your_email@example.com"接著它會問你幾個問題,新手照著做就好:
- 問你要存哪 → 直接按 Enter,預設存在
~/.ssh/id_rsa - 問你要不要設密碼短語(passphrase)→ 這裡直接按 Enter 留空
特別提醒:雖然設 passphrase 安全性更高,但在這個自動化流程裡,有密碼短語的金鑰會卡住 workflow(因為 Runner 沒辦法在跑到一半時手動輸入密碼)。想用有密碼的金鑰,得額外調整 workflow,新手先留空最省事。
跑完後你會得到一對檔案,可以這樣確認:
cat ~/.ssh/id_rsa # 私鑰,這把要保密,等等給 GitHub
cat ~/.ssh/id_rsa.pub # 公鑰,這把要登記到主機私鑰就像你家鑰匙,千萬別貼到公開的地方。公鑰則像門鎖的形狀,可以大方公開。
把公鑰登記到主機
把公鑰加進主機的授權清單,並設定權限(這步常被忽略,權限不對 SSH 會直接拒絕連線):
# 把公鑰內容加進授權清單(引號內換成你 id_rsa.pub 的內容)
echo 'ssh-rsa AAAA...你的公鑰...' >> ~/.ssh/authorized_keys
# 設定正確權限
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys把私鑰交給 GitHub Secrets
私鑰不能直接寫在 workflow 檔案裡(那等於把家門鑰匙貼在 GitHub 上給全世界看),要放進 Secrets 這個加密保險箱:
- 打開儲存庫 → Settings → 左側 Secrets and variables → Actions → New repository secret
- 名稱填
DEPLOY_SSH_KEY,內容貼上完整的私鑰(cat ~/.ssh/id_rsa的全部輸出,包含開頭結尾那兩行-----BEGIN.../-----END...)
順帶把主機位置也存成 secret,名稱 HOSTNAME,內容是你的 IP 或網域。之後需要用到的環境變數(資料庫連線字串、PORT 等)都用同樣方式加進來,避免硬寫在程式碼裡。
第二步:撰寫 GitHub Actions workflow
在專案根目錄建立 .github/workflows/deploy.yml。GitHub 會自動掃描這個資料夾,把裡面的 yml 當成自動化任務來執行。
name: 部署 PM2 專案
on:
push:
branches:
- main # 推到哪個分支就部署,依你的習慣調整
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: 取得程式碼
uses: actions/checkout@v4
- name: 安裝 Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: 安裝套件與建置
run: |
npm ci
npm run build --if-present
- name: 產生 .env 檔
run: |
echo "PORT=${{ secrets.ENV_PORT }}" >> .env
echo "DATABASE_URL=${{ secrets.DATABASE_URL }}" >> .env
- name: 載入 SSH 私鑰
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
- name: 信任主機(寫入 known_hosts)
run: |
mkdir -p ~/.ssh
ssh-keyscan ${{ secrets.HOSTNAME }} >> ~/.ssh/known_hosts
- name: 傳輸檔案到主機
run: rsync -avz --delete ./ root@${{ secrets.HOSTNAME }}:~/app/
- name: 重啟 PM2
run: ssh root@${{ secrets.HOSTNAME }} 'cd ~/app && pm2 restart www --update-env'讓我用白話拆解這幾個關鍵步驟,理解了你就能自己改:
- 取得程式碼 / 安裝 Node.js:在那台臨時 Runner 上把專案環境準備好。這裡我把版本更新到
@v4與 Node 20,原文是比較舊的@v1與 Node 18,舊版本現在會跳警告。 npm ci:比npm install更適合 CI 環境,它會嚴格依照package-lock.json安裝,速度快又穩定。- 產生 .env 檔:把放在 Secrets 的環境變數寫成
.env,等等隨檔案一起傳到主機。 ssh-keyscan:第一次連線時 SSH 會問「你信任這台主機嗎」,在自動化流程裡沒人能回答,所以我們預先把主機指紋寫進known_hosts,等於先簽好信任書。rsync --delete:只傳「有變動」的檔案,比整包覆蓋快很多;--delete會把主機上多餘的舊檔清掉,讓兩邊保持一致。pm2 restart --update-env:連進主機重啟服務,--update-env確保剛剛更新的環境變數會被吃進去。
第三步:看結果與踩雷補充
設定完成後,每次 push 到指定分支,到儲存庫上方的 Actions 分頁就能看到部署紀錄:
- 綠色勾勾 = 成功,點進去能看到每一步的詳細 log
- 紅色叉叉 = 失敗,一樣點進去看 log,錯誤訊息通常會直接告訴你卡在哪一步
第一次跑十之八九不會一次過,這很正常,我自己當年也是紅了好幾次才綠。最常見的雷有三個:私鑰沒貼完整(少了開頭結尾)、主機端 authorized_keys 權限不對、以及 rsync 路徑寫錯。對著 log 一步步排查就好,別氣餒。
最後兩個提醒。第一,GitHub Actions 不是無限免費,公開儲存庫免費,私有儲存庫每月有額度,用量可以在「個人設定 → Billing」查到,一般個人專案幾乎用不完。第二,前面說過,這套 PM2 流程是拿來理解原理用的,等你熟了,下一步可以把它換成 Docker,部署會更乾淨、更好搬家。
對了,如果你也在練習把專案部署上線,順手分享一個我自己做、同樣是「靜態建置後部署到雲端」的小成品:QR Code Studio,純靜態、開箱即用,當作部署練習的目標專案也很合適。希望這篇有幫你把「自動部署」這道坎踏過去,我們下篇見!