之前寫過部署到 GCP Cloud Run,那是託管的做法。這篇是另一端:服務跑在自己的 Ubuntu VM 上,但一樣做到「push 到 main 就自動上線」。
關鍵是整條鏈沒有任何 inbound port。不開 SSH 給 GitHub、不放 SSH 金鑰進 Secrets、不需要固定 IP——部署靠 self-hosted runner 主動輪詢領工作,對外服務靠 Cloudflare Tunnel 打洞,兩邊都是純 outbound。代價是 VM 的維運(磁碟清理、swap、安全更新)全部自己來,而且 runner 的執行身分等於一個能在你機器上跑指令的帳號,權限切分不能馬虎。
環境:Ubuntu Server 26.04.1 LTS、Docker Engine、GitHub Actions self-hosted runner、GHCR、Cloudflare Tunnel。
一、架構總覽
flowchart TD
DEV(["開發者 push 到 main"]) --> BUILD["GitHub Actions(雲端機器)
build job:建置 image
推上 GHCR,tag = commit SHA"]
BUILD --> DISPATCH["GitHub Actions
deploy job 排入佇列"]
RUNNER["Ubuntu VM:actions-runner
systemd 服務,以 deploy 身分執行"] -.->|"主動輪詢領工作
純 outbound"| DISPATCH
RUNNER --> COMPOSE["docker compose pull
up -d --wait"]
COMPOSE --> APP["Docker:api + db
api 只綁 127.0.0.1:3000"]
APP --> CFD["cloudflared"]
CFD -.->|"outbound 隧道"| CF["Cloudflare"]
CF --> URL(["https://api-staging.example.com"])
三個設計重點:
- Build 在 GitHub 雲端,VM 只負責 pull 與啟動。 VM 規格可以很小,build cache 也不會吃掉 VM 的磁碟。
- Pull-based 部署。 runner 主動去 GitHub 領工作,所以 VM 不需要公開 SSH、不需要固定 IP,也不用把 SSH 金鑰放進 GitHub Secrets。
- Image 用 commit SHA 當 tag。 每次部署都可追溯,回滾就是「部署舊的 SHA」。
二、事前準備
| 項目 | 說明 |
|---|---|
| Ubuntu VM | 26.04.1 LTS Server(建議 minimal,不裝桌面)。起步 2 vCPU / 4 GB RAM / 60 GB 磁碟;舒適 4 vCPU / 8 GB |
| 安裝時建立的管理員帳號 | 具 sudo 權限,以下稱「管理員」 |
| GitHub repo | 內含 Dockerfile 的服務,以下以 apps/api/ 為例 |
| 服務的健康檢查端點 | 例如 GET /health 回 200,部署驗證會用到 |
| Cloudflare 帳號 + 託管網域 | 用於 Tunnel,以下以 example.com 為例 |
⚠️ 只在 private repo 使用 self-hosted runner。 Public repo 的任何人都能開 PR,若 workflow 設定不慎,fork 的程式碼可能在你的 VM 上執行。
三、帳號分工:管理員 vs deploy
整篇會在兩個帳號之間切換,先把原則講清楚:
| 帳號 | 權限 | 負責 |
|---|---|---|
| 管理員 | 有 sudo | 系統層:apt、systemd 服務、防火牆 |
| deploy | 沒有 sudo,在 docker 群組 |
runner 設定(config.sh)、/srv/myapp 的檔案 |
為什麼 deploy 不給 sudo:它是 runner 執行 workflow 的身分。任何能修改 workflow 檔的人,等於能以 deploy 身分在 VM 上執行指令。它已經在 docker 群組(這本身就接近 root 等級),不需要再多給。
切換方式:
1 | # 從管理員切到 deploy |
四、安裝 Docker Engine
以管理員執行。 使用 Docker 官方 apt repo,不要用 Ubuntu 內建的 docker.io 套件,兩者不要混裝:
1 | sudo apt update && sudo apt install -y ca-certificates curl |
驗證:
1 | sudo docker run --rm hello-world |
💡 若
apt update對 Docker repo 回 404,代表 Docker 還沒替目前的 Ubuntu codename 建立套件庫。暫時把/etc/apt/sources.list.d/docker.list裡的 codename 改成前一版 LTS 的noble即可。
五、建立部署使用者與目錄
以管理員執行:
1 | sudo useradd -m -s /bin/bash deploy |
⚠️ 請在安裝 runner 服務之前把
deploy加進docker群組。如果順序顛倒,群組變更不會套用到已在執行的服務,需要重啟服務(見下面〈Docker socket permission denied〉)。
六、安裝 GitHub self-hosted runner
6.1 取得安裝指令
到 GitHub repo → Settings → Actions → Runners → New self-hosted runner,選 Linux / x64。頁面會列出下載網址、版本號、SHA256 校驗碼,以及一次性的註冊 token。
以下版本號與 token 一律以該頁面顯示的為準,不要照抄網路上的範例。
6.2 下載與解壓(以 deploy 執行)
1 | sudo -iu deploy |
⚠️ 一定要先
cd ~/actions-runner再解壓。 在家目錄直接解壓的話,bin/、config.sh、svc.sh等檔案會散落在家目錄。修正方式見下面〈Runner 檔案解壓到家目錄〉。
6.3 註冊 runner(仍以 deploy 執行)
1 | ./config.sh \ |
--labels staging 很重要,workflow 會用這個 label 指定「部署工作要派到這台」。
完成後回到管理員:
1 | exit |
6.4 安裝成 systemd 服務(以管理員執行)
svc.sh 需要 root 權限,而 deploy 沒有 sudo,所以由管理員執行,並用參數指定服務以 deploy 身分運行。
Ubuntu 的家目錄預設權限是 750,管理員無法直接 cd 進 /home/deploy,因此包在 sudo bash -c 裡:
1 | sudo bash -c 'cd /home/deploy/actions-runner && ./svc.sh install deploy && ./svc.sh start && ./svc.sh status' |
看到 active (running) 後,回 GitHub 的 Runners 頁面,確認 cd-vm 狀態為 Idle(綠燈)。
七、在 VM 上放機敏設定
資料庫密碼等機敏值只放在 VM 上,永遠不進 git。
以 deploy 執行:
1 | sudo -iu deploy |
💡 這裡為了簡化讓 api 與 db 共用同一份
.env。正式環境可拆成api.env與db.env,讓每個容器只拿到自己需要的變數。
八、Repo 端:docker-compose.yml
在 repo 建立 deploy/docker-compose.yml:
1 | name: myapp-staging |
四個容易忽略的地方:
name: myapp-staging固定 compose 專案名稱。不設的話,專案名會取自 compose 檔所在的資料夾名,之後調整目錄結構就會變成另一個專案,舊容器不會被替換。${IMAGE_NAME:?...}若 workflow 沒傳入變數,compose 會直接報錯,而不是默默拉到錯的 image。healthcheck是必要的。 下一節的up -d --wait會等到健康檢查通過才算部署成功;沒有 healthcheck 的話,--wait只會確認容器有啟動,服務壞掉也照樣綠燈。mem_limit防止單一容器失控拖垮整台 VM。
九、Repo 端:CD workflow
建立 .github/workflows/cd.yml:
1 | name: CD |
設計說明:
concurrency:連續 push 兩次時,兩個部署不會同時跑、互相踩到。${GITHUB_REPOSITORY,,}:bash 語法,把字串轉小寫。這是最常見的 GHCR 失敗原因之一。--wait加上Verify:健康檢查沒過,workflow 直接紅燈,手機上的 GitHub 通知就看得到。docker logout:不在 runner 的家目錄殘留登入憑證。GITHUB_TOKEN本身在 job 結束後就失效,這一步是保持環境乾淨。- private repo 從 GHCR 拉 image,用 workflow 內建的
GITHUB_TOKEN即可,不需額外建立 Personal Access Token。
十、對外服務:Cloudflare Tunnel
目前 api 只綁在 VM 的 127.0.0.1:3000。用 Cloudflare Tunnel 對外,VM 一樣不需要開任何 inbound port。
這裡採用「在 Cloudflare 後台建立 Tunnel、VM 只貼 token」的方式,設定集中在後台管理,VM 上不需要維護設定檔。(claude_linebot 那篇用的是在本機寫 config 的 named tunnel,兩種都可以。)
10.1 在 Cloudflare 後台建立 Tunnel
- 進入 Cloudflare Zero Trust → Networks → Tunnels → Create a tunnel
- 類型選 Cloudflared,命名為
myapp-staging - 安裝頁面會顯示一段含 token 的安裝指令,先複製 token
10.2 在 VM 安裝 cloudflared(以管理員執行)
1 | sudo mkdir -p --mode=0755 /usr/share/keyrings |
回到後台,Tunnel 狀態應變為 Healthy。
10.3 設定公開網址
在 Tunnel 的 Public Hostname 頁籤新增:
| 欄位 | 值 |
|---|---|
| Subdomain | api-staging |
| Domain | example.com |
| Service Type | HTTP |
| URL | 127.0.0.1:3000 |
儲存後,https://api-staging.example.com 就會導向 VM 上的 api。HTTPS 憑證由 Cloudflare 處理。
十一、防火牆與維運設定
以管理員執行。
11.1 防火牆
runner 與 cloudflared 都只需要 outbound,所以 inbound 可以全部擋掉。
⚠️ 如果你是透過 SSH 連線到 VM,一定要先放行 SSH 再啟用防火牆,否則會把自己鎖在外面。
1 | sudo ufw allow OpenSSH |
之後若把 SSH 也改走 Cloudflare Tunnel,就可以移除 OpenSSH 規則,達到完全零 inbound。
11.2 定期清理舊 image
每次部署都會拉新 image,舊的 layer 不會自動刪除,幾十次部署後磁碟就會滿:
1 | sudo tee /etc/cron.weekly/docker-prune > /dev/null << 'CRONEOF' |
until=168h 只清除超過 7 天未使用的資源,近期的 image 會保留下來,方便回滾。
💡
docker system prune不會刪除有名稱的 volume,資料庫資料是安全的。
11.3 Swap
防止部署瞬間(新舊容器交替、image 解壓)的記憶體尖峰觸發 OOM killer。先確認安裝程式是否已建立 swap:
1 | swapon --show |
若沒有輸出,再建立:
1 | sudo fallocate -l 2G /swapfile |
11.4 自動安全更新
1 | sudo apt install -y unattended-upgrades |
十二、驗證整條鏈
推一個 commit 到
main到 GitHub 的 Actions 頁面,確認
buildjob 在ubuntu-latest上執行並成功、deployjob 顯示執行在cd-vm上並成功在 VM 上確認容器狀態,
api與db都應顯示(healthy):1
2sudo -iu deploy
docker ps從任何地方開啟
https://api-staging.example.com/health,應回 200
十三、回滾
因為每個 image 都以 commit SHA 標記,回滾就是「部署某個舊的 SHA」:
- 在 GitHub 的 commit 列表找到要回滾到的 commit,複製完整 SHA
- 到 Actions → CD → Run workflow
image_tag欄位貼上該 SHA,執行
這時 build job 會被跳過,deploy 直接拉取既有的舊 image 部署。
💡 只要舊 image 還在 GHCR 上,就能回滾。GHCR 的 image 預設不會自動刪除;VM 端的
docker system prune只影響本機快取,不影響 GHCR。
十四、擴充:加上 Production 與手動核准
同一台 VM 可以先同時跑 staging 與 production,之後有需要再拆到不同機器。
14.1 註冊第二個 runner
1 | # 以 deploy 執行 |
14.2 準備 production 的 compose 與設定
- 複製一份
deploy/docker-compose.prod.yml,把name:改成myapp-prod、port 改成127.0.0.1:3001:3000、env_file改成/srv/myapp-prod/.env - 在 VM 建立
/srv/myapp-prod/.env(步驟同第七節) - 在 Cloudflare 新增一個 Tunnel 或 Public Hostname,例如
api.example.com→127.0.0.1:3001
14.3 設定手動核准
- 到 repo 的 Settings → Environments → New environment,命名為
production - 勾選 Required reviewers,加入你自己
在 workflow 加一個 job:
1 | deploy-prod: |
Staging 部署成功後,GitHub 會通知你核准。在手機的 GitHub app 上按下核准,就會部署到 production。
十五、疑難排解
sudo: I'm sorry deploy. I'm afraid I can't do that
原因:deploy 沒有 sudo 權限。Ubuntu 26.04 預設使用 sudo-rs,這是它對不在 sudoers 名單的使用者的回應。這是預期行為,不是錯誤。
解法:需要 sudo 的指令(svc.sh、apt、ufw)改由管理員執行。不要為了方便把 deploy 加進 sudo 群組。
Runner 檔案解壓到家目錄
症狀:ls ~ 看到 bin/、externals/、config.sh、svc.sh 等檔案,而 ~/actions-runner/ 是空的。
先確認是否已註冊:
1 | ls -la ~ | grep -E '\.runner|\.credentials' |
若尚未註冊(沒有 .runner),直接搬移:
1 | cd ~ |
若已註冊或已安裝服務,最乾淨的做法是註銷後在正確位置重新安裝:
1 | # 以管理員:移除服務 |
然後清掉家目錄的 runner 檔案,依第六節重新安裝。
Docker socket permission denied
症狀:deploy job 報 permission denied while trying to connect to the Docker daemon socket。
原因:deploy 被加入 docker 群組之前,runner 服務就已經啟動,服務程序拿不到新的群組權限。
解法:
1 | # 確認群組 |
Runner 在 GitHub 顯示 Offline
1 | sudo systemctl status 'actions.runner.*' |
常見原因:VM 無法連外(檢查 DNS 與 outbound 防火牆)、服務未啟動、註冊 token 過期(需重新註冊)。
GHCR push 或 pull 失敗
invalid reference format:image 名稱含大寫。確認 workflow 有${GITHUB_REPOSITORY,,}那一步。denied:確認 workflow 頂層有permissions: packages: write;若 package 是從另一個 repo 建立的,到 GHCR package 設定頁的 Manage Actions access 把目前 repo 加進去。
apt update 對 Docker repo 回 404
Docker 尚未支援目前的 Ubuntu codename。把 /etc/apt/sources.list.d/docker.list 中的 codename 改為 noble 後重新 apt update。
部署顯示成功,但服務其實壞了
檢查 docker-compose.yml 的 api 是否有 healthcheck。沒有的話 --wait 只確認容器已啟動。另外確認 healthcheck 使用的指令(wget/curl)在 image 中確實存在:
1 | docker compose -p myapp-staging ps |
磁碟空間不足
1 | df -h |
確認 11.2 節的每週清理有設定。
附錄:檔案清單
Repo 內(進版控):
1 | .github/workflows/cd.yml |
VM 上(不進版控):
1 | /srv/myapp/.env # 機敏設定,chmod 600 |
同分類的相關文章:
- GitHub Actions 自動部署(CD)到 GCP Cloud Run 完整教學——同一條 CD 思路的託管版,用 Workload Identity Federation 免金鑰認證
- claude_linebot 部署教學:LINE channel、Cloudflare Tunnel 與開機自動啟動——Cloudflare Tunnel 的另一種設定方式(named tunnel 加本機 config)
- 用 Hexo + GitHub + Cloudflare Pages 免費架部落格——靜態網站那一端的自動部署
留言