← 返回部落格
·16 min 閱讀·DevOps

在 Mac + OrbStack 上搭一條 GitHub Actions 部署管線:從 0 到實際踩過的所有坑

把多個獨立 GitHub repo 的 service 部署到一台 Mac 的完整實戰:自架 self-hosted runner、用 GHCR 管 image、組織遷移、Keychain 問題、Multi-arch build、Docker plugin 路徑等等真實踩過的工程細節。

GitHub ActionsCI/CDDockerGHCROrbStackSelf-hosted Runner
GP Wang
GP Wang
GWP4 STUDIO 創辦人 · 軟體 / 資料工程師

前言

我有一台 Mac mini 跑著 ~10 個 service(Next.js 前端、NestJS 後端、Python pipeline、爬蟲...),全部用 OrbStack 跑 docker container,前面用一個 nginx-proxy 反向代理到不同子域名。每次更新 code 都得手動 git pull && docker compose up -d,極度不敏捷也容易出錯。

這篇是把這個爛流程接到 GitHub Actions 自動化的全紀錄。重點不是「最終的 workflow 長怎樣」(網路上一搜一堆),而是從零開始會踩到的每個坑。如果你也想在 Mac/Linux 自家機器跑 self-hosted runner 配 GitHub Actions,這篇可以幫你少花幾小時。

架構決策:先想清楚再動手

Repo 結構:要不要 monorepo?

第一個問題:所有 code 要塞同一個 repo 嗎?

我已經有 ~40 個分散的 repo。盤點完現況我選擇了 「保持 polyrepo + 加一個 deploy hub repo」

  • 各 service repo 維持獨立(frontend、backend、pipeline 各自的 GitHub repo)
  • 多開一個 deploy hub repo(這篇用 usj 為例)只放 docker-compose.yml + 部署 workflow

為什麼不 monorepo?

  • 既有 service 已經各自獨立、技術棧不同
  • CI 觸發判斷麻煩,要 path filter 才能避免改一個 service 全部 rebuild
  • 40 個 repo 合併代價極高

為什麼也不純 polyrepo(不開 deploy hub)?

  • nginx config / docker-compose 整合需要一個歸屬
  • 跨 service 的部署順序、healthcheck、rollback 策略要集中

Stage / Prod 環境怎麼切?

最初我想做完整的 stage + prod 雙環境,但實務上 stage 用不到那麼大。最後簡化成:

  • 沒有常駐 stage。要驗證新版就在本機 docker compose up
  • prod 是唯一線上環境,由 deploy hub 編排部署
  • 失敗自動 rollback 到部署前的 image tag

Image build vs deploy 的職責分離

關鍵設計:每個 subrepo 自己 build & push image 到 GHCR;deploy hub 只負責 pull image + docker compose up

gwp4-studio/usj-frontend  ─┐
                           ├─ push GHCR (各自 build workflow)
gwp4-studio/usj-backend   ─┤
gwp4-studio/usj-pipeline  ─┘
                              ↓
                gwp4-studio/usj (deploy hub)
                  ├─ docker-compose.yml (uses ${IMAGE_TAG} for each)
                  └─ deploy workflow:
                        pull GHCR images
                        docker compose up -d (in-place 替換)
                        healthcheck
                        失敗 auto-rollback

這樣 deploy hub 沒有 source code、沒有 submodule,只有 orchestration。

自架 self-hosted runner 的核心觀念

GitHub Actions 預設給雲端 runner(ubuntu-latest 之類),但我要部署到自家 Mac,需要自架 runner。

Runner 不是 webhook,是 long-poll

很多人誤以為 self-hosted runner 是 GitHub「主動連你機器」。實際上是反過來:runner 進程主動 long-poll GitHub「有 job 給我嗎?」。所以你 Mac 不需要公網 IP、不用開 port,跟瀏覽器逛網頁同一個方向。

你 Mac (~/actions-runner/run.sh)
    │ HTTPS 出站連線 (hold 30s)
    ▼
github.com Actions queue
    │ 派 self-hosted job
    ▼
runner 收到 → fork worker → 逐步執行 workflow YAML

Runner scope 三種:repo / org / enterprise

我一開始把 runner 註冊在單一 repo (gpwork4u/usj),但這代表未來每個 repo 都要各自註冊一台 runner。後來改成註冊在 GitHub Organization 層級,一個 runner 服務 org 內所有 repo。

這個決定也順便逼我把分散的 personal repo 搬到一個 org 下,整理了帳號結構。

實際動手:一個一個踩坑

接下來是真正花時間的部分。每個小段都是一個踩過的坑。

坑 1:GitHub Free 的 private repo 不能用 required reviewers

原本想在 deploy workflow 設 environment: production 加 required reviewer 當 gating。結果 API 回:

Failed to create the environment protection rule. 
Please ensure the billing plan supports the required reviewers protection rule.

GitHub Free plan 的 private repo 不支援 environment protection rules(required reviewers)。要嘛升級 Pro,要嘛 repo 設 public,要嘛換別的 gating 機制。

我的解法:把 workflow 改成只接受 workflow_dispatch(手動觸發)。push main 不會自動部署,你想 deploy 才主動跑:

on:
  workflow_dispatch:    # push main 不再自動觸發
    inputs:
      frontend_tag: { default: 'prod' }
      backend_tag:  { default: 'prod' }
      pipeline_tag: { default: 'prod' }

「主動觸發」就是新的 gating——只是把「approval」搬到「dispatch」這個動作上。

坑 2:docker login 在 launchd service 裡會撞 macOS Keychain

裝完 self-hosted runner,跑第一次 deploy,第一個 step 就死了:

error saving credentials: error storing credentials - 
err: exit status 1, out: `User interaction is not allowed. (-25308)`

這是 Mac 經典坑:runner 用 ./svc.sh install 裝成 launchd background service,這個 service 沒有 GUI session。Docker 預設用 osxkeychain credstore 存登入憑證,Keychain 在沒 GUI session 的進程下無法解鎖

一開始的錯誤嘗試

我先試了「讓 docker login 寫到隔離的 config dir」:

env:
  DOCKER_CONFIG: ${{ github.workspace }}/.docker-config

steps:
  - name: Login GHCR
    run: |
      mkdir -p "$DOCKER_CONFIG"
      echo '{"auths":{}}' > "$DOCKER_CONFIG/config.json"
      echo "$TOKEN" | docker login ghcr.io -u "$USER" --password-stdin

但 docker login 還是偷叫 keychain。Mac 的 docker CLI 即使你 DOCKER_CONFIG 隔離,還是會嘗試用 keychain helper。

真正的解法:繞過 docker login

直接把 base64 auth 寫進 config.json 不調用 docker login

- name: Setup isolated DOCKER_CONFIG with auth
  env:
    GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  run: |
    mkdir -p "$DOCKER_CONFIG"
    AUTH=$(printf '%s:%s' "${{ github.actor }}" "$GHCR_TOKEN" | base64)
    cat > "$DOCKER_CONFIG/config.json" <<EOF
    {
      "auths": {
        "ghcr.io": { "auth": "$AUTH" }
      }
    }
    EOF

完全不執行 docker login,自然也不會去碰 keychain。

坑 3:DOCKER_CONFIG 隔離後 docker compose plugin 找不到了

修好 keychain 問題後,下一個 step 又炸:

unknown shorthand flag: 'p' in -p
Usage:  docker [OPTIONS] COMMAND [ARG...]

docker compose -p usj 為什麼會被當成 docker -p?因為 compose 沒被識別為 docker subcommand

原因:docker compose(v2)是個 CLI plugin,docker 從這幾個位置找:

  • $DOCKER_CONFIG/cli-plugins/
  • $HOME/.docker/cli-plugins/

當我把 DOCKER_CONFIG 設成隔離目錄,plugin 路徑跟著改了,但隔離目錄是空的。docker 找不到 compose plugin → 把 compose 當 positional arg → 然後解析 -p 為 docker 自身的 flag → 報錯。

解法:在隔離 config 裡 symlink 原本的 plugins:

mkdir -p "$DOCKER_CONFIG/cli-plugins"
for plugin in /Users/$(whoami)/.docker/cli-plugins/*; do
  ln -sf "$plugin" "$DOCKER_CONFIG/cli-plugins/$(basename "$plugin")"
done

坑 4:launchd service 的 PATH 沒有 /usr/local/bin

修好 plugin 又下一個錯:docker 命令本身找不到。

launchd 的預設 PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin,沒有 /usr/local/bin(OrbStack 把 docker 裝在那)也沒有 /opt/homebrew/bin

GitHub Actions 有專用機制 $GITHUB_PATH 可以擴充 PATH:

- name: Setup PATH
  run: |
    echo "/usr/local/bin" >> $GITHUB_PATH
    echo "/opt/homebrew/bin" >> $GITHUB_PATH
    echo "/Users/$(whoami)/.orbstack/bin" >> $GITHUB_PATH

或永久解:在 ~/actions-runner-xxx/.path 寫入完整 PATH。

坑 5:macOS bash 3.2 不支援 ${var^^} 大寫展開

我寫了個迴圈 resolve image tag:

for svc in frontend backend pipeline; do
  VAR="${svc^^}_TAG"          # bash 4+ 的大寫展開
  tag="${!VAR}"
  ...
done

GitHub Actions 預設用 /bin/bashMac 的 /bin/bash 還是 3.2(被 GPL v3 卡住,Apple 從 2007 沒升級)。${var^^} 是 bash 4 才有的語法,3.2 跑不動。

簡單的修法:用 case

for svc in frontend backend pipeline; do
  case "$svc" in
    frontend) tag="$FRONTEND_TAG" ;;
    backend)  tag="$BACKEND_TAG"  ;;
    pipeline) tag="$PIPELINE_TAG" ;;
  esac
  ...
done

或在 shebang 用 /usr/bin/env bash(如果 PATH 裡有 brew 裝的新 bash)也行。

坑 6:GHCR 跨 repo pull image 預設拒絕

當 repo gwp4-studio/usj-frontend 的 build workflow push image 到 ghcr.io/gwp4-studio/usj-frontend,這個 image 預設只有 source repo 自己的 workflow 能 pull

當我從 gwp4-studio/usj (deploy hub) 想 pull 它,回應:

Error response from daemon: denied

雖然兩個 repo 在同一個 org 內,但 GHCR 不自動信任。要嘛:

方法 A:image 設 public(簡單但失去隱私)

gh api -X PATCH /orgs/gwp4-studio/packages/container/usj-frontend \
  -f visibility=public

方法 B:對每個 image 加授權 repo(保持私有,但只能 web UI 操作)

https://github.com/orgs/<org>/packages/container/<name>/settings
→ Manage Actions access
→ Add Repository → 選 deploy hub repo → Role: Read

我選了 B,因為 image 內容雖然不是高度敏感,但保持「跟 source repo 一致的可見性」比較對齊心智模型。

坑 7:Image 是 amd64,但 Mac 是 arm64

修完 GHCR 權限,docker pull 開始動作了。然後:

no matching manifest for linux/arm64/v8 in the manifest list entries

GitHub-hosted runner 預設是 ubuntu-latest(amd64),build 出來的 image 也是 amd64-only。我的 Mac 是 M2(arm64),pull 不到對應 manifest。

解法:build multi-arch image。在每個 subrepo 的 build.yml 加 QEMU + 指定 platforms:

- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    platforms: linux/amd64,linux/arm64    # ← 關鍵
    tags: |
      ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
      ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:prod

代價:QEMU emulate arm64 build 會慢 2-5 倍。frontend Next.js build 可能從 2 分鐘變 10 分鐘。

如果你 Org 是 Pro 以上 plan,可用 GitHub 提供的 arm64 native runnerubuntu-24.04-arm)跳過 emulation,但 Free plan 沒這個額度。

部署目錄的設計:別把 deploy 跟 dev workspace 混在一起

一個容易忽略的細節:deploy 過程中產生的檔案要放哪?

錯誤示範

最直覺的做法是 runner 把 compose 檔 rsync 到本機 dev workspace /Volumes/2tb/project/usj/,然後在那邊跑 docker compose up

問題:

  • dev workspace 同時是「你開發改 code 的地方」跟「prod 部署目錄」
  • 你本機 docker compose up 拿來開發會撞到 prod container
  • git pull 拉 main 後 IDE 不小心改檔,下一次 deploy rsync 蓋掉你的 WIP
  • .env.prod 放在 dev 目錄容易被 IDE 索引、git status 看到

正確做法:ephemeral tmp dir

我最後改成每次 deploy 都用 mktemp 新建一個目錄,跑完就 rm:

- name: Deploy in ephemeral tmp dir
  run: |
    TMP=$(mktemp -d -t deploy-usj.XXXXXX)
    trap "rm -rf $TMP" EXIT
    
    cp docker-compose.yml docker-compose.prod.yml "$TMP/"
    cd "$TMP"
    
    docker compose -p usj \
      -f docker-compose.yml -f docker-compose.prod.yml pull
    docker compose -p usj \
      -f docker-compose.yml -f docker-compose.prod.yml up -d --remove-orphans

兩個關鍵:

  1. -p usj 顯式指定 project name:不靠目錄名推斷。否則 tmp dir 名亂七八糟,docker 會以為每次都是不同 project,看不到既有容器
  2. trap "rm -rf $TMP" EXIT:不管成功失敗都清掉

docker daemon 自己管 container/volume/network 的 state,compose 檔本來就只是 spec,apply 完可以丟。

Secrets 不放 Mac 上

部署需要的密碼(DB_PASSWORD 等)原本想放 /Volumes/2tb/deployments/usj/.env.prod。但 GitHub Environment Secrets 完全可以替代:

deploy:
  environment: production
  env:
    DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
    # 其他 env vars 直接由 workflow shell env 注入

Mac 本機完全沒有 .env.prod 持久檔,secrets 在 docker container 跑起來後存在 container env 裡。Mac 重灌也不用搬密碼

In-place 替換:搭便車 OrbStack 自動 DNS

最神奇的部分:整個 deploy 過程不用動 nginx

我的 nginx-proxy 是 OrbStack 上跑的容器,反代到其他 service:

location / {
    proxy_pass http://frontend.usj.orb.local:3000;
}

*.orb.localOrbStack 自動生的 DNS,hostname 規則是 <service>.<project>.orb.local

當 deploy 時 docker compose up -d 替換 frontend 容器:

  1. 舊容器停止(image 是 usj-frontend:latest
  2. 新容器啟動(image 是 ghcr.io/.../usj-frontend:prod
  3. 新容器仍然在 project usj、service frontend同樣的 OrbStack hostname
  4. nginx 下次 request 進來,re-resolve DNS,連到新容器

整個過程 nginx 完全沒重啟、沒 reload、沒任何感知。中間有 ~5 秒新容器啟動時間,期間 nginx 會回 502,但對個人服務可接受。

要做到這個 in-place 替換,COMPOSE_PROJECT_NAME 跟 service name 不能變。其他都可以動。

Rollback:用 docker inspect 抓部署前的 tag

部署前先 snapshot:

- name: Snapshot current running tags
  id: snap
  run: |
    for svc_container in frontend:usj-frontend-1 backend:usj-backend-1 pipeline:usj-pipeline; do
      svc="${svc_container%%:*}"
      cn="${svc_container##*:}"
      PREV=$(docker inspect --format='{{.Config.Image}}' "$cn" 2>/dev/null \
             | awk -F: '{print $NF}' || true)
      echo "${svc}_prev=${PREV}" >> "$GITHUB_OUTPUT"
    done

部署失敗自動回滾:

- name: Auto-rollback
  if: failure() && steps.snap.outcome == 'success'
  env:
    FRONTEND_TAG: ${{ steps.snap.outputs.frontend_prev }}
    BACKEND_TAG:  ${{ steps.snap.outputs.backend_prev }}
    PIPELINE_TAG: ${{ steps.snap.outputs.pipeline_prev }}
  run: |
    TMP=$(mktemp -d) && trap "rm -rf $TMP" EXIT
    cp docker-compose.yml docker-compose.prod.yml "$TMP/"
    cd "$TMP"
    docker compose -p usj -f docker-compose.yml -f docker-compose.prod.yml pull
    docker compose -p usj -f docker-compose.yml -f docker-compose.prod.yml up -d
    exit 1

不需要額外存 state,docker daemon 自己記得每個 container 用什麼 image。

學到什麼

寫到這邊回頭看,做這個東西最大的收穫不是「workflow 怎麼寫」,而是這幾個更廣泛的判斷:

1. 部署架構的職責切分比技術選型重要

  • build(產 binary)跟 deploy(裝上線)是兩件事,分開做才不糾結
  • dev workspaceprod runtime 是兩件事,混在一起遲早出意外
  • source stateruntime state 是兩件事,前者在 git,後者在 docker daemon
  • 觸發機制(push / dispatch)跟 gating(reviewer / approval)是兩件事

2. macOS 跑 server 你會撞到一堆 Linux 沒有的問題

  • launchd PATH 跟 user shell PATH 不一樣
  • 內建 bash 永遠是 3.2
  • Keychain 沒 GUI session 解不開
  • BSD 工具跟 GNU 工具旗標不同
  • ARM64 build target 不是 default

對個人服務 Mac 是夠用的,但別期待跟 Linux server 一樣 smooth。每個小坑都要花 10-30 分鐘處理。

3. Free plan 確實夠用,但要繞過幾個限制

  • Required reviewers 沒有 → 改用 workflow_dispatch 當 gating
  • ARM64 native runner 沒有 → 用 QEMU emulate
  • 跨 repo 共用 runner:Personal account 不行,要開 Organization(也是 Free)

如果你個人服務、單機部署,Free plan + 一個 org + 一台 self-hosted runner 完全 cover。

4. 寫好錯誤訊息可以省下別人很多時間

我寫這篇是因為過程中每個錯誤訊息都讓我卡 5-30 分鐘,stackoverflow / GitHub issue 的回答常常只是片段。希望這篇能讓下一個踩坑的人省點時間。

結語

從「每次手動 ssh 過去 docker compose up」到「push code → 自動 build → 一個指令觸發 deploy」,這條路上有很多細節是看官方文件看不出來、得實際撞牆才知道的。

如果你也想做類似事情,建議先把架構決策(單 repo / 多 repo / runner 在哪)想清楚再動手,動手過程中碰到上面這些坑就照著解就好。最終你會得到一個不需要你開瀏覽器、不需要 ssh、不需要記指令的部署機制——push 一下,等通知就好。

關於作者

GP Wang
GP Wang

GWP4 STUDIO 創辦人,超過 8 年軟體開發經驗,專精資料工程、全端開發與系統架構。 持續在部落格分享專案實作經驗與技術心得。

了解更多 →