CASE STUDY / RELEASE ENGINEERING
我原本只是想解決「Frontend 和 Backend 如何安全地分開發布」,最後逐漸發現真正需要處理的不只是 Pipeline,而是整個 Release Workflow。
Gitea · Drone CI · FastAPI · SQLite · Docker · Portainer · Synology NAS · Approval · Scheduled deployment
2026-09 更新:Release Controller 自己的部署已從 Podman + Shell Script 遷移到 Drone CI + Portainer + Docker,過程記錄在第 8 節。
1. CI/CD 已經存在,為什麼還需要 Release Controller?
這個專案的起點並不是「想做一套 DevOps 平台」。現有環境本來就已經在用 Gitea 與 Drone CI,build 跟 pipeline 都跑得起來。真正卡住的是 build 成功「之後」的那一段。
一條單純的 CI pipeline 並沒有替我回答這些問題:
- Frontend 和 Backend 想分開 Release,可以嗎?
- 這個版本現在在 Pre-production 還是 Production?
- 誰決定要 Promote 到正式環境?有沒有人核准過?
- 上一次上線是什麼時候、誰做的、結果如何?
- 能不能先排好時間,而不是要人守在電腦前面按?
CI success 不等於應該直接進 Production。
CI 回答的是「這份程式碼能不能被正確建置」,而不是「現在該不該把它放到正式環境」。後者是一個包含環境、時機與人的決策,所以我把它拆出來,做成一個獨立的服務:Release Controller。
2. 把 CI 和 Release Decision 分開
Release Controller 不是另一套 CI Server。Drone 仍然負責實際的 build 與 pipeline 執行,Release Controller 站在它後面,只管一件事:
何時、由誰、把哪個版本 Promote 到哪個環境。
Drone CI 負責
- Build
- Pipeline 執行
- 產出可被 Promote 的 build
Release Controller 負責
- Environment selection / Promote
- Approval workflow
- Release status 與 history
- Notification、Scheduled deployment
3. Architecture
下面是抽象化後的架構圖。圖中刻意不包含任何 host、repository 名稱、port 或憑證資訊。

Developer → Gitea → Drone CI
build & pipeline
↓
RELEASE CONTROLLER
Environment / Promote · Approval
Release History (audit) · Notification · Scheduling
↓
DEPLOYMENT TARGET
Pre-production
↓
Human Approval
↓
Production
┄┄┄
IN PROGRESS / PLANNED
Rollback · Role-based permissions
這張圖描述的是 Release Controller「管理別的服務發布」的流程。Release Controller 自己是怎麼被建置和部署的,是另一條路徑,放在第 8 節。
技術選擇
Backend 用 Python + FastAPI,搭配 SQLAlchemy / Alembic 與 SQLite;部署面是 Gitea 與 Drone CI,Release Controller 本身目前以 Docker container 運行、由 Portainer Git Stack 管理(早期是 Podman + shell script,遷移過程見第 8 節);通知走 SMTP。
為什麼是 SQLite?
Release Controller 本身的資料規模不大,主要保存 release request、status、history、approval 相關資訊與 deployment metadata。在這個規模下,為了「架構看起來更企業級」而強行部署一套大型 DB,換來的是更多的部署與維運成本,而不是更好的系統。
SQLite 的好處很直接:部署簡單、備份容易、適合單一服務、維運成本低。在目前規模下,我優先降低部署與維護複雜度;這不代表它永遠是最終方案,而是代表現階段還沒有需要換掉它的理由。
4. Frontend 和 Backend 可以分開 Release
這是整個專案最早的需求。Frontend 與 Backend 的變更週期本來就不一致:Backend 修一個邏輯不一定需要重新部署 Frontend,Frontend 調整 UI 也不一定需要 Backend Release。
Release 不應該被綁成「每次都必須整套一起上」。
所以 Release Controller 允許各個元件個別 Promote,也允許把多個元件打包成一次 Release 依序執行。Operator 可以各自挑選每個元件要用的 build;打包發布時若其中一個元件失敗,系統會標示為部分失敗,並允許只重試失敗的那個元件,而不是把整批重跑一次。
5. Debugging Container Configuration:ENV 沒有被注入
這是我覺得最值得寫下來的一個 case,因為它示範了一件事:Configuration bug 不是 Application bug,但表現出來會很像。
Application starts
↓
Integration fails
↓
Inspect container environment
↓
Expected variables are empty
↓
Trace compose / env configuration
↓
Fix environment injection
↓
Verify inside container
Problem
Release Controller 的容器啟動之後,服務本身看起來是正常的:process 有起來、healthcheck 也回得來。但所有與 Drone 整合的功能都不能用。
Evidence
進到容器內部去看 runtime environment,才發現 Drone 相關的 environment variables 全部是空值:
DRONE_SERVER = (empty)
DRONE_TOKEN = (empty)
SMTP_HOST = (empty)
...
這是一個很重要的分界點:程式沒有錯,程式只是拿到了空字串。
Investigation
排查方向集中在三個地方:environment file 本身、container 的 bind / injection 設定,以及實際的 runtime environment。三者只要有一層對不上,容器裡看到的就會是空的。
Root Cause
ENV_FILE 沒有正確被帶進容器,所以後續所有由它衍生的變數自然都是空的。
Fix & Verification
修正 environment injection 的設定之後,最關鍵的一步不是「重啟服務看功能好了沒」,而是回到容器內部確認變數真的存在。功能好了可能只是巧合,變數存在才是證據。
Environment variables 必須在 runtime 被驗證,而不是在設定檔裡被相信。
6. From Local Container to Synology NAS
Release Controller 部署到 Synology NAS 的理由很單純:NAS 本來就是一台持續運作的 on-premise node,能跑 container,適合當內部工具的 hosting environment。我沒有打算把它當成 Cloud Platform,它就是一台一直開著的機器。
UI Deployment vs Declarative Deployment
過程中用過 Container Manager、Portainer、Compose 與 SSH CLI。Portainer 拿來「看」container 狀態非常方便,但正式的設定我盡量收斂成可重現的形式:Compose、Environment File、Volume、Restart Policy。
差別在於:UI 上點出來的設定只存在於那一台機器的記憶裡,宣告式的設定則可以被讀、被 diff、被重建。
後來 Portainer 的角色從「看」變成了「部署」,但方向沒有退回 UI 設定:改用 Git Stack 之後,部署規格仍然是 repository 裡的 compose.portainer.yml,Portainer 負責拉取與執行,細節在第 8 節。
Container Persistence
因為資料放在 SQLite,容器部署就一定要處理 persistent volume。否則 container rebuild 或 recreate 之後,release history 會跟著消失——而 release history 正是這個系統存在的理由之一。
Release Controller Container
↓
Persistent Volume
↓
SQLite
Container is disposable, data is not.
Automatic Startup
另一件容易被忽略的事:container 在主機重新開機之後會不會自己起來。restart policy、NAS reboot、service startup 這三件事沒處理好,服務就只是「現在還活著」而已。早期用 Podman 部署時,這件事需要自己另外處理;遷移到 Docker 之後,改由 compose 裡的 restart: unless-stopped 描述。
「Container 能跑」和「Service 可以長期運作」是兩件不同的事。
Permission denied
部署過程也遇過 script 無法執行、直接回 Permission denied 的情況。排查方向包含 execute permission、container mount、script ownership 與 runtime user。
這類問題提醒我一件事:Deployment 同時也是 filesystem 與 runtime 的工程,不是把程式包進 image 就結束了。
7. SSH Is Part of Deployment Engineering
把 git remote 改用 SSH、設定 key authentication、處理 Permission denied (publickey)、調整 Gitea 的 SSH 設定、處理 known_hosts 與自訂連接埠——這些事情加起來佔掉的時間,比我預期的多很多。
結論不是「SSH 很難」,而是:CI/CD 整合不只涉及 Application Code,還牽涉 Trust、Key Management 與 Host Verification。誰有權限推、哪一台主機可以被信任、金鑰放在哪裡,這些都是系統設計的一部分。
後來 Release Controller 自己的部署改走 Portainer API,Drone 不再需要 SSH 進部署主機。SSH 仍然用在 git 操作上,但 CI 端握有的主機權限少了一層。
8. 從 Shell Deployment 到 Portainer:Release Controller 自己的部署
前面幾節談的是 Release Controller 怎麼管理其他服務的 release。這一節換個方向,記錄它自己是怎麼被部署的。這條部署路徑最近從 Podman + Shell Script 遷移到 Drone CI + Portainer + Docker。原本的做法可以工作,這次改動的重點不是換工具,而是把 CI、部署授權和 runtime 管理這幾件事的責任重新分開。
原本的部署流程
Gitea
↓
Drone CI
↓
SSH 到部署主機
↓
deploy-release-controller.sh
↓
Podman build
↓
Podman rm / run
↓
health check
↓
必要時 rollback
這條流程能動,也確實跑了一段時間。但 build、停掉舊 container、起新 container、health check、失敗時 rollback,全部集中在一支 shell script 裡;而 Drone 要能執行它,就必須持有部署主機的 SSH 權限。
為什麼不再讓 Drone SSH 主機
讓 CI Runner 能 SSH 進部署主機,等於 CI 端握有一把能在主機上做任何事的鑰匙,第 7 節提到的 key management 和 host verification 也因此直接變成部署路徑的一部分。除此之外,還有幾個問題逐漸浮現:
- Deployment、runtime 與 rollback 的邏輯耦合在同一支 script 裡,改一處容易牽動另一處
- Script 依賴主機上的路徑、權限與已安裝的工具,主機環境的差異會直接變成部署失敗
- 要搬到另一台 server 時,需要重新建立的部署設定很多,而且有不少只存在於那台主機上
所以這次的方向是:Drone 只負責「驗證」和「授權部署哪個 commit」,實際怎麼把 container 起起來,交給專門管理 runtime 的 Portainer。
現在的部署架構

重新整理之後,每個元件只負責一件事:
- Gitea:Source Control,程式碼與部署參考(main、deploy branch、version tag)的來源
- Drone CI:CI、Quality Gate 與部署授權
- Portainer:部署與 runtime 管理
- Docker:Runtime
- Release Controller:release orchestration 應用本身
Gitea
↓
Drone CI Quality Gate
↓
更新 deploy branch
↓
Drone 呼叫 Portainer API
↓
Portainer Pull Git Stack(compose.portainer.yml)
↓
Docker build / redeploy
↓
/health 驗證
↓
建立版本 Git Tag
↓
Drone release pipeline 建立 Gitea Release
Drone 作為 Quality Gate
Repository 裡目前有三條 pipeline:quality、deploy、release。其中 quality 是所有部署的前提:
quality
├─ frontend-quality npm ci → npm run quality
├─ python-quality install requirements-dev.txt
│ → ruff check → ruff format --check
│ → pytest + coverage
└─ browser-e2e Playwright E2E
只有 quality pipeline 成功,main 上的那個 commit 才有資格進入 deploy。
deploy Branch 作為 Deployment Pointer
Repository 裡多了一條 deploy branch,但它不拿來開發功能。它的角色很單純:指向「已經通過 Drone CI、允許 Portainer 部署」的那個 commit。
main
開發與 Source of Truth
deploy
通過 Drone CI、允許 Portainer 部署的 commit pointer
這樣設計是為了避免一個時間差:如果 Portainer 直接追 main,當 main 上已經有新的 commit B、而 CI 還在驗證 commit A 的時候,Portainer 一 redeploy 就可能拉到還沒驗證過的 B。
加上 deploy branch 之後,流程變成 main → Drone CI → deploy → Portainer。Drone 在 promote-deploy-ref 這一步把通過驗證的 DRONE_COMMIT_SHA 更新到 deploy branch,Portainer 只看 deploy。CI Gate 和 runtime deployment 分成兩段,中間用一個明確的 Git ref 交接。
Portainer Git Stack
Portainer 這邊用 Git Stack 管理 Release Controller:stack 綁定 repository 的 deploy branch 和 repo 內的 compose.portainer.yml。收到 redeploy 請求時,Portainer 會 pull 最新的 deploy branch,依 compose 定義 build 並重新部署 container。下面是簡化過的片段,只留下跟部署契約有關的部分:
# compose.portainer.yml(簡化示意)
services:
release-controller:
build:
context: .
restart: unless-stopped
ports:
- "<host-port>:8000"
volumes:
- <persistent-host-dir>:/data
env_file:
- stack.env
environment:
DATABASE_URL: sqlite:////data/release.db
healthcheck:
test: ... # 容器內呼叫 GET /health
Drone 的 portainer-redeploy 步驟用 Portainer API Token 呼叫 Git Stack 的 redeploy API,整個過程不需要 SSH 進主機。container 狀態、logs、restart 和 stack 管理也都在 Portainer 上,日常查看不用登入主機下指令。
Docker Runtime 與資料持久化
第 6 節提過,release history 是這個系統存在的理由之一,所以資料持久化是這次遷移時特別保留的部署契約:SQLite 在 container 內的位置固定為 /data/release.db,host 端用 bind mount 保存 /data。
Portainer redeploy / image rebuild
↓
新的 Release Controller container
↓
bind mount → /data
↓
release.db(保留)
container 可以重建、image 可以重新 build、Portainer 可以 redeploy,SQLite 的資料都不會跟著 container 消失。從 Podman 換到 Docker,runtime 變了,但「資料在 /data、容器可以拋棄」這件事沒有變。
Secret 與 Config 分離
進版本控制
compose.portainer.yml:部署規格.env.example:環境變數規格與範本
不提交到 Git
stack.env/ Portainer Stack Environment Variables:實際部署環境設定- DRONE_TOKEN、GITEA_TOKEN、APP_SECRET_KEY
- OAuth secret、SMTP password、Portainer API Token
Config-as-Code ≠ Secret-as-Code。
compose 與 .env.example 描述的是「需要哪些設定、長什麼樣子」,可以被 review、被 diff;真正的值由部署環境保存。第 5 節那次 ENV 沒被注入的經驗,也讓我更在意這條界線:設定的形狀放在 Git,值放在環境,兩邊對不上的時候要能在 runtime 查得出來。
部署成功後才建立 Release
deploy
├─ release-preflight 檢查 pyproject.toml version
│ 確認同版本 tag 沒有指向另一個 commit
├─ promote-deploy-ref DRONE_COMMIT_SHA → deploy branch
├─ portainer-redeploy Portainer API → Git Stack redeploy
├─ verify-deployment 呼叫 /health,等待新 container ready
└─ tag-release 確認部署成功後才建立 vX.Y.Z tag
release(refs/tags/v* 觸發)
└─ plugins/gitea-release → 建立對應的 Gitea Release
順序是刻意安排的。版本 tag 不是在 build 成功時打,而是等 Portainer 實際部署完、/health 也確認有回應之後才建立;Gitea Release 則由這個 tag 觸發的 release pipeline 產生。
CI build 成功不等於 release 成功,部署完成並通過 health check 才算。
release-preflight 放在最前面,是為了在動到任何東西之前先擋掉版本衝突:如果 pyproject.toml 的版本號已經有 tag,而且指向另一個 commit,deploy 就不會開始。反過來,如果 /health 驗證沒過,pipeline 會停在那一步,不會產生版本 tag,也就不會有對應的 Gitea Release。
Before / After
BEFORE
Drone → SSH → Shell Script → Podman → Container
- Deployment logic 集中在 shell script
- CI Runner 需要 SSH 進部署主機
- Runtime、deployment、rollback 邏輯耦合
- 對主機環境的依賴較高
- 搬到其他 server 時要重建較多部署設定
AFTER
Drone CI → Quality Gate → deploy ref → Portainer API → Git Stack → Docker → health verification
- Drone 不再 SSH 到部署主機
- CI 與 runtime management 分離
- 部署規格由
compose.portainer.yml描述 - Portainer 提供 container / logs / restart / stack 管理
- 環境差異集中在 Environment Variables
- 比較容易搬到另一台 self-hosted server 做 Demo
這不是把所有部署問題都解決了,比較準確的說法是一次部署架構的簡化與責任分離。也有一塊還沒補上:舊 script 裡「失敗時 rollback」的邏輯,在新架構裡目前沒有對應的自動化。health 驗證失敗會讓 pipeline 失敗、不建立 tag,但不會自動退回上一個 image。
接下來的演進方向
PLANNED(規劃中,尚未實作)
目前 Portainer 是從 Git 拉原始碼、在部署主機上 build image。下一步考慮改成 artifact-based 的部署:
Drone CI
↓
Build immutable Docker image
↓
Gitea Container Registry
↓
Portainer pull image:<commit-sha>
這樣部署出去的就是 CI 驗證過的同一個 image,rollback 也有機會變成「換回上一個 commit-sha 的 image」。另一個近期想做的是把同一份 compose.portainer.yml 搬到自己的 Home Server 做作品 Demo,只換 Environment Variables,不需要把原環境的 IP、token、secret 或資料一起帶走。
也把目前沒有的東西說清楚:這套部署沒有使用 Kubernetes,沒有 Blue/Green 或 Canary,Portainer 不是 HA、也沒有多節點 Docker cluster;單一 container 的 redeploy 不提供 zero downtime 保證,也還沒有自動 image rollback。
9. Human-in-the-loop Production Deployment
Release Controller 的核心設計之一,是承認「不是所有 CI success 都應該直接 Production Deploy」。
Build Success
↓
Release Request
↓
Review / Approval
↓
Promote
↓
Deploy
↓
Health Check
↓
Record Result
Environment model 上採取的是 default-safe deployment strategy:預設的 Release Target 偏向 Pre-production / Testing,Production Release 則需要更高程度的確認。正常情況先進測試/預備環境,再由人決定是否 Promote 到 Production。
目前 Approval workflow 已經有明確的狀態轉移(PENDING → APPROVED / REJECTED → DEPLOYING → SUCCESS / FAILED),在正式環境發布前提供人為檢查點。權限的部分仍在完善中——完整的角色權限控制還沒有做完,這件事我寫在下面的 Roadmap 裡,而不是假裝它已經完成。
10. Current Status
這一節以 repository 目前的實際狀態為準。會分成三塊,是因為我不想讓一份作品集文章把「規劃中」寫成「已上線」。
WORKING
- 多專案架構:可在 UI 設定專案、元件與部署順序
- Drone / Gitea 連線登錄,token 加密儲存與連線測試
- 從 Drone build 歷史動態取得分支,各元件可獨立挑選 build
- 單一或打包 Promote,可設定執行順序
- Source build 與 promotion build 分開追蹤,含 timeout 管理
- Deployment 與 publish 狀態分離:支援 Gitea release、bundle 部分失敗、只重試失敗的元件
- Approval workflow:
PENDING → APPROVED / REJECTED → DEPLOYING → SUCCESS / FAILED - Release history:append-only 的 audit event 紀錄
- Scheduled deployment:支援 IANA 時區,由背景 worker 執行並可回復中斷的工作
- SMTP notification:outbox、去重、timeout claim、指數退避重試、附件
- Gitea OAuth2 + PKCE 登入與 server-side session
- 依專案元件動態產生的 BPMN 流程視覺化
- Release Controller 自身以 Docker container 運行、由 Portainer Git Stack 管理;Drone 分為 quality / deploy / release 三條 pipeline,部署並通過
/health後才建立版本 tag 與 Gitea Release
IN PROGRESS
- Rollback:目前系統能做到的是「只重試失敗的元件」與 bundle 部分失敗處理;完整的 rollback 機制仍在持續完善,尚未完成。
- 更完整的權限控制:目前的認證已經接上 Gitea OAuth2,但角色層級的授權仍在開發中。
PLANNED
- 完整的 role-based permissions
- Service token
- Gitea webhook 簽章驗證
- Release Controller 自身改為 artifact-based 部署:CI build immutable image → Gitea Container Registry → Portainer 部署
image:<commit-sha> - Release Controller 自身部署失敗時的自動 image rollback
這個系統目前定位為內部工具,運作在受信任的內部網路環境中,因此上面那些仍在規劃中的項目是 roadmap,而不是被忽略的缺口。
11. 這個專案帶來的實際價值
我不打算在這裡放「部署時間減少 80%」這種數字,因為我沒有可靠的量測資料可以支撐它。能誠實描述的是流程能力上的改變:
- Release 流程更集中,操作入口統一
- Production deployment 可以加入 approval,而不是誰有權限誰就能上
- Release history 比人工操作更容易追蹤:什麼、什麼時候、哪個環境、結果如何
- Frontend / Backend 可以分別控制發布節奏
部署不再只是「有人 SSH 進去打了一串 command」,而是一個留得下紀錄的流程。
12. What I Learned
- CI 和 Release Management 是不同的問題。前者處理「能不能建出來」,後者處理「該不該放上去」。
- Deployment configuration 也是 application architecture 的一部分。ENV、volume、restart policy 不是附屬設定,它們決定了系統的實際行為。
- Environment variables 必須在 runtime 驗證。設定檔寫對了不代表容器裡拿得到。
- Container 解決了 packaging,但不會自動解決 persistence、startup 與 permission。
- Production release 需要 human control。自動化的目標是讓人做決定時更有資訊,而不是把人排除在外。
- 小型內部工具也值得有清楚的 release history。規模小不是沒有稽核需求的理由。
- 部署架構重構的重點是責任分離,不是換工具。Podman + shell script 本來就能工作;把 CI、部署授權與 runtime 管理拆開之後,每一段的責任邊界比較清楚。
- Config 可以進 Git,Secret 不行。Compose 與 .env.example 描述設定的形狀,真正的值留在部署環境。
Tech Stack
Python · FastAPI · SQLAlchemy · Alembic · SQLite · React · TypeScript · Gitea · Drone CI · Docker · Docker Compose · Portainer · Playwright · pytest · Ruff · Synology NAS · SSH · SMTP · BPMN