我的伺服器在客廳充電——用約束逼出來的自架架構
我的伺服器沒有固定公網 IP、沒有在路由器上設任何 port forwarding、也沒有租任何一台雲端主機。它是一台放在家裡、24 小時開著、偶爾會被家人不小心闔上蓋子的 Windows 筆電。
上面同時對外服務著三個網域,外加一個刻意不對外的後台:
www.example.com— 作品集網站(Next.js 16,四主題、雙語、blog 存在 MongoDB、科技新聞由 RSS 自動抓)database.example.com— 通用 MongoDB 管理後台knowledge.example.com— 知識庫 MCP 後端(給程式化 client 用)- (只在本機)
portfolio-admin— 個人網站內容後台
它們各自有獨立的 repo、獨立的網域、獨立的認證方式,彼此只透過資料契約與 webhook 對話。對外的最後一哩全部交給 ngrok 的自訂網域,DNS 掛在 GoDaddy。
這篇不是自架教學。 這類「怎麼設 ngrok、怎麼點 GoDaddy」的內容 Google 上一抓一大把。我想談的是另一件更有意思的事:當你的約束是「一台會被合上蓋子的家用筆電」時,架構會被逼成什麼形狀——以及在這條路上,DNS、Windows 排程器與 MongoDB 索引分別是怎麼教訓我的。
一、先講約束:為什麼不租一台 VPS?
一台入門 VPS 一個月幾塊美金,能省下我後面所有的麻煩。所以先誠實回答:租 VPS 當然更省事,但我要的不是「一個能跑的網站」,而是把一整套服務的邊界、生命週期與安全模型,親手設計並維運一遍。手邊剛好有一台閒置筆電、一個已經買了的網域、一個付費 ngrok 帳號——約束就這樣定了下來:
- 沒有公網 IP、不能開 port(家用 NAT + 浮動 IP)
- 沒有雲端預算(host 就是這台筆電)
- host 隨時可能離線(睡眠、Windows Update 重開、被闔上蓋子)
後面每一個設計決策,幾乎都能回指到這三條其中之一。
二、系統全貌:一服務,一網域,一套認證
使用者 / MCP client
│
GoDaddy DNS (example.com)
│
ngrok 邊緣 (自訂網域 + TLS + traffic-policy)
├── www.example.com ........ 作品集 Next.js :3000 ─┐
├── database.example.com ... Mongo 後台(OAuth+限流) :3300 ─┤
└── knowledge.example.com .. 知識庫 MCP(Ed25519+限流) │
▼
portfolio-admin(本機限定 :3400) ───────────────► MongoDB :27017(loopback,共用)
〔以上全部跑在同一台 Windows 筆電〕
我沒有把四個服務塞進同一個 Next.js 專案,而是依信任邊界拆分,而不是依技術框架拆分。理由是爆炸半徑控制:database 後台被打穿,不會波及作品集;知識庫的簽章金鑰外洩,不會動到 OAuth 那條線;每個服務可以獨立部署、獨立改版、獨立設定認證。
需要誠實標注的是:這裡的「低耦合」指的是 repo / 部署 / 網域 / 認證 的低耦合,不代表高可用——它們仍然共享同一台主機、同一條家用網路、同一個 ngrok agent,以及(見下文)同一個 MongoDB。這是我為了省一台 mongod 而刻意接受的耦合。
三、對外那一哩路:ngrok 自訂網域 + GoDaddy DNS
ngrok 把「動態 IP + NAT + 不能開 port」這個問題整個消掉了:agent 從筆電主動建立到 ngrok 邊緣的通道,外部流量從 *.example.com 進到邊緣,再被推回筆電。GoDaddy 只負責兩件事:名稱解析,與根網域轉址。真正花時間的是 DNS 的兩個坑:
1. apex(裸網域)不能設 CNAME。 DNS 規範不允許在 zone apex 放 CNAME,而 ngrok 自訂網域是靠 CNAME 接的。解法是:子網域 www 設 CNAME 指向 ngrok,裸網域 example.com 則用 GoDaddy 的 HTTP 網域轉址回傳 301 到 https://www.example.com。
一個常見的誤解要澄清:301 是 HTTP 行為,不是 DNS 行為。 是 GoDaddy 的轉址伺服器在 HTTP 層回 301,不是「DNS 把裸網域 301 過去」。(若哪天要讓裸網域直接指向 ngrok,正解是換到支援 ALIAS / CNAME flattening 的 DNS,如 Cloudflare、Route 53。)
2. GoDaddy 免費 Website Builder 會鎖住你的 DNS。 我一開始怎麼改 www 的 CNAME 都是唯讀。原因是那個免費建站服務透過 Domain Connect 託管了 @ 與 www 記錄。得先把站與網域斷開,DNS 才解鎖。這種「明明是我的網域卻不能改」的坑,是自架才會撞到、教學文很少寫的。
四、讓它自己活著:把 Windows 排程器當成 init system
約束第 3 條(host 隨時會離線)決定了整個運維設計:服務必須開機自啟、免登入、當掉自癒。 我沒有裝 Docker、也沒有用 PM2,而是把 Windows 工作排程器壓榨成一個窮人版的 systemd / K8s。每個服務(站、tunnel、mongod、RSS)一個排程任務,組合出這些可靠性原語:
| 需求 | K8s 的做法 | 我的做法(Windows 排程) |
|---|---|---|
| 開機自啟 | kubelet | BootTrigger |
| 崩潰重啟 | restartPolicy | RestartOnFailure 999 次 / 每 1 分 |
| 活性探測 | livenessProbe | 每 5 分鐘 TimeTrigger watchdog |
| 併發控制 | — | MultipleInstancesPolicy: IgnoreNew |
| 免登入執行 | — | S4U principal |
| 電池不中斷 | — | DisallowStartIfOnBatteries: false |
重點不在於「我設了排程」,而在於我理解的是可靠性的抽象,而不是某個工具的按鈕位置。這張對照表也順帶帶出一個我必須誠實承認的漏洞——
「進程活著」不等於「服務可用」。 我的 watchdog 目前是進程級的:它確認 launcher 進程還在,但如果 Node 進程沒死、event loop 卻卡住,或 port 被佔用,排程器會以為一切正常。真正嚴謹的做法應該是打一個 HTTP health endpoint 來判定。這是我清楚知道、列在待辦上的一個真實限制。
(另一個 S4U 的小陷阱:它產生的 token 沒有網路憑證,只能跑純本機服務;還好我所有東西都在 localhost。而且 SSH session 下 USERDOMAIN 是空的,所以排程 principal 必須用 SID 而不是 DOMAIN\user。)
五、安全不是一道門,而是多個不同的信任邊界
我沒有做「一組帳密守全部」,而是依使用者類型分化認證——認證方式是從「誰在用」推導出來的,不是抄來的:
| 服務 | 使用者 | 認證 |
|---|---|---|
| 作品集 | 任何人 | 公開,無認證 |
| database 後台 | 人(瀏覽器) | ngrok 邊緣 Google OAuth(只允許我的 email)+ app 層密碼 |
| knowledge MCP | 程式(無瀏覽器) | Ed25519 簽章 + nonce(防重放)+ 限流 |
瀏覽器後台用 OAuth 很自然;但 MCP 後端是程式化 client,沒有瀏覽器可以跑 OAuth 流程,所以改用簽章。兩者都額外疊一層,形成縱深防禦。
為什麼刻意不用 IP 白名單? 表面理由是「我的 IP 會變」;更深的理由是:IP 白名單是把身分綁在網路位置上,這在移動辦公的前提下本來就是錯的抽象——這正是 zero-trust / BeyondCorp 的核心論點。身分應該綁在「你是誰」(OAuth email、簽章金鑰),不是「你從哪連」。
不過自架的安全代價也要說清楚:TLS 在 ngrok 邊緣終止,意思是 ngrok 技術上看得到我服務的明文流量(包含後台密碼)。這不是 bug,是把入口外包的必然。也正因如此,知識庫那條走的是 Ed25519 端到端簽章——完整性保證不依賴邊緣,這個對比剛好凸顯了兩種威脅模型的差異。
六、部署:私有 repo → 一台沒有 GitHub 憑證的筆電
我的發布流程有明確關卡:開發機寫 + localhost 測 → 本人確認 → commit/push 到 GitHub(private)→ 才上筆電。 不在伺服器上直接 git pull。
有個細節值得一提:筆電上刻意不放 GitHub 長期憑證。 筆電是最容易被實體接觸的機器,我不想在上面擺一把能存取我所有私有 repo 的鑰匙。所以 code 傳上筆電是用 git bundle——把 commit 打包成單一檔案 scp 過去,在筆電上從 bundle clone。部署因此變成單向、離線的資料傳輸。犧牲一點便利,換到更小的憑證暴露面。這是安全思維,不是偷懶。
七、真正花時間的,是這些故障(根因,不只是解法)
流水帳式的「踩雷清單」沒意思;有價值的是每個雷背後被修正的心智模型:
- 「SSH session 是乾淨的執行環境」——錯。 它是一個會累積狀態的長壽 shell。我在部署第一個 app 時設了
NODE_ENV=production,它殘留到第二個 app 的npm install,害它略過了 devDependencies,build 直接掛。修正不是「記得 unset」,而是每個服務都用 wrapper script 顯式宣告完整環境,不繼承任何東西。 sparseunique index 忽略的是「欄位不存在」,不是null。 MongoDB driver 預設會把undefined寫成null——而null對 sparse index 來說是一個合法、會被索引的值。於是第二筆沒有slug的文件就撞 duplicate key。解法是ignoreUndefined: true。這個精準的區分,比「undefined 存成 null 會出錯」有洞見得多。- SSH 進 Windows 跑 PowerShell,長輸出與中文會截斷、亂碼。 與其跟串流搏鬥,不如把邏輯本機寫成
.ps1→scp上去 → 遠端執行寫進 log → 再把 log 讀回來。把「執行」和「輸出」解耦。 - 失敗的 Turbopack build 會留下毒快取。 一次 build 失敗後,即使補齊了依賴,
.next的快取仍讓它報同樣的錯——清掉.next重新 build 才乾淨。
八、我知道它哪裡會壞
主動列出自己架構的弱點,是我認為資深與初階最明顯的分野。這套架構換來了完全的控制權與一次徹底的學習,但它犧牲了這些,我心裡有數:
- ngrok 是單點故障,也是信任邊界。 ngrok agent 或服務本身出事,四個服務同時全掛,而我的 watchdog 救不了(那不在我的機器上)。外加供應商鎖定與頻寬計費、家用上行頻寬也是瓶頸。
- 共用 MongoDB 是唯一的共同故障元件。 它掛掉會讓作品集與後台一起倒;schema 變更時兩個 repo 要一起改。這是典型的 shared-database anti-pattern,個人專案這樣完全合理,但我知道它是耦合點。
- 資料層還很陽春。 單機
mongod(綁 loopback、尚未開 RBAC)、沒有 replica set、沒有 PITR。「系統 DB 唯讀 + drop 要打字確認」是 UX 護欄,不是安全邊界——真正的邊界應該是 MongoDB 帳號權限。備份與 RBAC 都在待辦上。 - 健康檢查跑在那台可能已經掛掉的機器上。 需要一個外部 uptime 監控(dead man's switch),筆電斷網我才會知道。
- 筆電當伺服器的實體代價: Windows Update 強制重啟、睡眠與快速啟動、闔蓋、長期插電的散熱與電池膨脹。對應措施(關快速啟動、設 active hours、電源永不睡眠)本身就是「真的跑過 24 小時」的證據。
結語:自架的價值,是理解邊界
如果只看結果,這台筆電做的事,一台三塊美金的 VPS 也能做。但自架真正的產物不是「省下一台 VM」,而是把每一條邊界都親手劃過一次:網路的邊界(ngrok 如何改變故障域而非消除它)、生命週期的邊界(進程活著不等於服務可用)、信任的邊界(身分綁在你是誰,不是你從哪連)、以及便利與安全之間那條線。
什麼時候我會改用雲端?當可用性要求超過「單機 + 自癒」能給的、或當資料重要到不能只靠一顆 SSD 時。但在那之前,這台在客廳充電的筆電,已經幫我把一整套系統的邊界都想清楚了。而那,才是我真正想要的東西。