架站筆記

自家 Ubuntu VM 的零開 Port CD:push 到 main 就自動上線

2026-09-23 #GitHub Actions#CI/CD#Cloudflare Tunnel#Docker#Ubuntu#self-hosted

之前寫過部署到 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
2
3
4
5
# 從管理員切到 deploy
sudo -iu deploy

# 從 deploy 回到管理員
exit

四、安裝 Docker Engine

以管理員執行。 使用 Docker 官方 apt repo,不要用 Ubuntu 內建的 docker.io 套件,兩者不要混裝:

1
2
3
4
5
6
7
8
9
10
11
sudo apt update && sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

驗證:

1
2
sudo docker run --rm hello-world
docker compose version

💡 若 apt update 對 Docker repo 回 404,代表 Docker 還沒替目前的 Ubuntu codename 建立套件庫。暫時把 /etc/apt/sources.list.d/docker.list 裡的 codename 改成前一版 LTS 的 noble 即可。

五、建立部署使用者與目錄

以管理員執行:

1
2
3
4
5
6
sudo useradd -m -s /bin/bash deploy
sudo usermod -aG docker deploy

sudo mkdir -p /srv/myapp
sudo chown deploy:deploy /srv/myapp
sudo chmod 750 /srv/myapp

⚠️ 請在安裝 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
2
3
4
5
6
7
8
9
sudo -iu deploy

mkdir -p ~/actions-runner && cd ~/actions-runner

# 以下兩行從 GitHub 頁面複製
curl -o actions-runner-linux-x64-<版本>.tar.gz -L <頁面上的下載網址>
echo "<頁面上的SHA256> actions-runner-linux-x64-<版本>.tar.gz" | shasum -a 256 -c

tar xzf ./actions-runner-linux-x64-<版本>.tar.gz

⚠️ 一定要先 cd ~/actions-runner 再解壓。 在家目錄直接解壓的話,bin/、config.sh、svc.sh 等檔案會散落在家目錄。修正方式見下面〈Runner 檔案解壓到家目錄〉。

6.3 註冊 runner(仍以 deploy 執行)

1
2
3
4
5
6
./config.sh \
--url https://github.com/<OWNER>/<REPO> \
--token <頁面上的註冊TOKEN> \
--name cd-vm \
--labels staging \
--unattended

--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
2
3
4
5
6
7
8
9
10
11
sudo -iu deploy

cat > /srv/myapp/.env << 'ENVEOF'
POSTGRES_USER=app
POSTGRES_PASSWORD=請換成強密碼
POSTGRES_DB=app
DATABASE_URL=postgres://app:請換成強密碼@db:5432/app
ENVEOF

chmod 600 /srv/myapp/.env
exit

💡 這裡為了簡化讓 api 與 db 共用同一份 .env。正式環境可拆成 api.env 與 db.env,讓每個容器只拿到自己需要的變數。

八、Repo 端:docker-compose.yml

在 repo 建立 deploy/docker-compose.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
name: myapp-staging

services:
api:
image: ${IMAGE_NAME:?IMAGE_NAME is required}:${IMAGE_TAG:?IMAGE_TAG is required}
restart: unless-stopped
env_file: /srv/myapp/.env
ports:
- "127.0.0.1:3000:3000" # 只綁 localhost,對外交給 Cloudflare Tunnel
depends_on:
db:
condition: service_healthy
healthcheck:
# 依你的 image 內有的工具調整;alpine 系 image 可用 busybox 的 wget
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 10s
timeout: 3s
retries: 5
start_period: 15s
mem_limit: 512m

db:
image: postgres:17
restart: unless-stopped
env_file: /srv/myapp/.env
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 10s
timeout: 3s
retries: 5
mem_limit: 768m

volumes:
pgdata:

四個容易忽略的地方:

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
name: CD

on:
push:
branches: [main]
workflow_dispatch:
inputs:
image_tag:
description: "要部署的 image tag(commit SHA)。用於回滾。"
required: true

permissions:
contents: read
packages: write

# 同一時間只允許一個部署,後來的會等前一個跑完
concurrency:
group: deploy-staging
cancel-in-progress: false

jobs:
build:
if: github.event_name == 'push'
runs-on: ubuntu-latest # 在 GitHub 雲端 build,不吃 VM 資源
steps:
- uses: actions/checkout@v4

# GHCR 的 image 名稱必須全小寫,repo 名稱若含大寫會 push 失敗
- name: Set image name
run: echo "IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY,,}/api" >> "$GITHUB_ENV"

- uses: docker/setup-buildx-action@v3

- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- uses: docker/build-push-action@v6
with:
context: ./apps/api
push: true
tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

deploy:
needs: build
# build 在手動回滾時會被跳過,仍要讓 deploy 執行
if: always() && (needs.build.result == 'success' || needs.build.result == 'skipped')
runs-on: [self-hosted, staging] # 派到 VM 上的 runner
env:
IMAGE_TAG: ${{ inputs.image_tag || github.sha }}
steps:
- uses: actions/checkout@v4

- name: Set image name
run: echo "IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY,,}/api" >> "$GITHUB_ENV"

- name: Login to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin

- name: Deploy
run: |
echo "Deploying $IMAGE_NAME:$IMAGE_TAG"
docker compose -f deploy/docker-compose.yml pull
docker compose -f deploy/docker-compose.yml up -d --wait --remove-orphans

- name: Verify
run: curl -fsS --retry 5 --retry-delay 3 http://127.0.0.1:3000/health

- name: Logout
if: always()
run: docker logout ghcr.io

設計說明:

  • 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

  1. 進入 Cloudflare Zero Trust → Networks → Tunnels → Create a tunnel
  2. 類型選 Cloudflared,命名為 myapp-staging
  3. 安裝頁面會顯示一段含 token 的安裝指令,先複製 token

10.2 在 VM 安裝 cloudflared(以管理員執行)

1
2
3
4
5
6
7
8
9
10
11
sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | \
sudo tee /usr/share/keyrings/cloudflare-main.gpg > /dev/null

echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main" | \
sudo tee /etc/apt/sources.list.d/cloudflared.list

sudo apt update && sudo apt install -y cloudflared

# 貼上後台給的 token
sudo cloudflared service install <TUNNEL_TOKEN>

回到後台,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
2
3
4
5
sudo ufw allow OpenSSH
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw enable
sudo ufw status verbose

之後若把 SSH 也改走 Cloudflare Tunnel,就可以移除 OpenSSH 規則,達到完全零 inbound。

11.2 定期清理舊 image

每次部署都會拉新 image,舊的 layer 不會自動刪除,幾十次部署後磁碟就會滿:

1
2
3
4
5
sudo tee /etc/cron.weekly/docker-prune > /dev/null << 'CRONEOF'
#!/bin/sh
docker system prune -af --filter "until=168h"
CRONEOF
sudo chmod +x /etc/cron.weekly/docker-prune

until=168h 只清除超過 7 天未使用的資源,近期的 image 會保留下來,方便回滾。

💡 docker system prune 不會刪除有名稱的 volume,資料庫資料是安全的。

11.3 Swap

防止部署瞬間(新舊容器交替、image 解壓)的記憶體尖峰觸發 OOM killer。先確認安裝程式是否已建立 swap:

1
swapon --show

若沒有輸出,再建立:

1
2
3
4
5
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

11.4 自動安全更新

1
2
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

十二、驗證整條鏈

  1. 推一個 commit 到 main

  2. 到 GitHub 的 Actions 頁面,確認 build job 在 ubuntu-latest 上執行並成功、deploy job 顯示執行在 cd-vm 上並成功

  3. 在 VM 上確認容器狀態,api 與 db 都應顯示 (healthy):

    1
    2
    sudo -iu deploy
    docker ps
  4. 從任何地方開啟 https://api-staging.example.com/health,應回 200

十三、回滾

因為每個 image 都以 commit SHA 標記,回滾就是「部署某個舊的 SHA」:

  1. 在 GitHub 的 commit 列表找到要回滾到的 commit,複製完整 SHA
  2. 到 Actions → CD → Run workflow
  3. image_tag 欄位貼上該 SHA,執行

這時 build job 會被跳過,deploy 直接拉取既有的舊 image 部署。

💡 只要舊 image 還在 GHCR 上,就能回滾。GHCR 的 image 預設不會自動刪除;VM 端的 docker system prune 只影響本機快取,不影響 GHCR。

十四、擴充:加上 Production 與手動核准

同一台 VM 可以先同時跑 staging 與 production,之後有需要再拆到不同機器。

14.1 註冊第二個 runner

1
2
3
4
5
6
7
8
9
10
# 以 deploy 執行
sudo -iu deploy
mkdir -p ~/actions-runner-prod && cd ~/actions-runner-prod
# 下載、解壓步驟同 6.2
./config.sh --url https://github.com/<OWNER>/<REPO> --token <新TOKEN> \
--name cd-vm-prod --labels prod --unattended
exit

# 以管理員執行
sudo bash -c 'cd /home/deploy/actions-runner-prod && ./svc.sh install deploy && ./svc.sh start'

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 設定手動核准

  1. 到 repo 的 Settings → Environments → New environment,命名為 production
  2. 勾選 Required reviewers,加入你自己

在 workflow 加一個 job:

1
2
3
4
5
6
7
8
9
deploy-prod:
needs: deploy
runs-on: [self-hosted, prod]
environment: production # 觸發手動核准
env:
IMAGE_TAG: ${{ inputs.image_tag || github.sha }}
steps:
# 步驟同 deploy job,compose 檔改用 deploy/docker-compose.prod.yml
# Verify 的 port 改成 3001

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
2
ls -la ~ | grep -E '\.runner|\.credentials'
systemctl list-units 'actions.runner*' --all --no-pager

若尚未註冊(沒有 .runner),直接搬移:

1
2
3
4
cd ~
mv _diag _work bin externals config.sh env.sh run.sh run-helper.sh \
run-helper.sh.template run-helper.cmd.template safe_sleep.sh svc.sh \
actions-runner/ 2>/dev/null

若已註冊或已安裝服務,最乾淨的做法是註銷後在正確位置重新安裝:

1
2
3
4
5
# 以管理員:移除服務
sudo bash -c 'cd /home/deploy && ./svc.sh stop && ./svc.sh uninstall'

# 以 deploy:從 GitHub Runners 頁面取得 remove token 後註銷
cd ~ && ./config.sh remove --token <REMOVE_TOKEN>

然後清掉家目錄的 runner 檔案,依第六節重新安裝。

Docker socket permission denied

症狀:deploy job 報 permission denied while trying to connect to the Docker daemon socket。

原因:deploy 被加入 docker 群組之前,runner 服務就已經啟動,服務程序拿不到新的群組權限。

解法:

1
2
3
4
5
# 確認群組
id deploy

# 重啟 runner 服務(以管理員)
sudo systemctl restart 'actions.runner.*'

Runner 在 GitHub 顯示 Offline

1
2
sudo systemctl status 'actions.runner.*'
sudo journalctl -u 'actions.runner.*' -n 50 --no-pager

常見原因: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
2
docker compose -p myapp-staging ps
docker inspect --format '{{json .State.Health}}' myapp-staging-api-1

磁碟空間不足

1
2
3
df -h
docker system df
sudo docker system prune -af --filter "until=24h"

確認 11.2 節的每週清理有設定。

附錄:檔案清單

Repo 內(進版控):

1
2
3
4
.github/workflows/cd.yml
deploy/docker-compose.yml
deploy/docker-compose.prod.yml # 選用
apps/api/Dockerfile

VM 上(不進版控):

1
2
3
/srv/myapp/.env                  # 機敏設定,chmod 600
/home/deploy/actions-runner/ # runner 本體
/etc/cron.weekly/docker-prune # 每週清理

同分類的相關文章:

Creative Commons 姓名標示 非商業性

本文採用 CC BY-NC 4.0 授權

歡迎轉載與引用,請標明出處(自家 Ubuntu VM 的零開 Port CD:push 到 main 就自動上線 — mur mur);禁止用於商業用途。

商業使用或合作提案,歡迎來信洽談:[email protected]

留言
分享

留言