在 Mac + OrbStack 上搭一條 GitHub Actions 部署管線:從 0 到實際踩過的所有坑
把多個獨立 GitHub repo 的 service 部署到一台 Mac 的完整實戰:自架 self-hosted runner、用 GHCR 管 image、組織遷移、Keychain 問題、Multi-arch build、Docker plugin 路徑等等真實踩過的工程細節。
前言
我有一台 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 hubrepo(這篇用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/bash,Mac 的 /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 runner(ubuntu-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
兩個關鍵:
-p usj顯式指定 project name:不靠目錄名推斷。否則 tmp dir 名亂七八糟,docker 會以為每次都是不同 project,看不到既有容器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.local 是 OrbStack 自動生的 DNS,hostname 規則是 <service>.<project>.orb.local。
當 deploy 時 docker compose up -d 替換 frontend 容器:
- 舊容器停止(image 是
usj-frontend:latest) - 新容器啟動(image 是
ghcr.io/.../usj-frontend:prod) - 新容器仍然在 project
usj、servicefrontend→ 同樣的 OrbStack hostname - 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 workspace 跟 prod runtime 是兩件事,混在一起遲早出意外
- source state 跟 runtime 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 一下,等通知就好。
關於作者

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