TL;DR
我讓 Claude Code 一條龍把一個 React 遊戲部署上 Firebase Hosting,再接上自己的網域 j7.yanchen.app,全程我只下一句「上架到這個網域」。
關鍵不是firebase deploy那行指令——那只要 30 秒;難的是「怎麼判斷真的上線了」。部署印Deploy complete!、Console 顯示「已連結」、單次curl回 200,三個都不算數。
這篇把整條流程(build → deploy → 接 custom domain → 設 DNS → 等 SSL → round-trip 驗證)寫下來,重點放在那個讓我卡了幾小時的坑:custom domain 顯示「已連結」卻一直 404,以及為什麼那其實不是設定錯。
把一個寫好的前端專案丟上線,聽起來是已經被解決一百遍的事。npm run build、firebase deploy,完工。但真的接上自己的網域、要對外人說「你打這個網址就能玩」的時候,中間有一段沒人會在教學裡寫清楚的灰色地帶——指令全綠了,網站卻時好時壞。
這篇是我用 Claude Code 把一個 4×3 棋盤對戰遊戲(戰旗 J7 Reborn)部署到 j7.yanchen.app 的完整紀錄。流程本身不難,但我想把「怎麼確認真的上線」這件事講透,因為那才是會讓你半夜還在 F5 的部分。
📌 目錄
- 為什麼是 Firebase Hosting
- 部署流程:四個指令的事
- SPA 的隱藏地雷:深連結 404
- 接 custom domain:DNS 與 SSL
- 最大的坑:已連結卻一直 404
- round-trip 驗證:handshake 不算上線
- 把流程交給 AI 的邊界
- ❓ 常見問題
- 🔗 延伸資源
⚡ 為什麼是 Firebase Hosting
先回答最務實的問題:一個純前端的 Vite/React build 產物,要放哪。
我的考量很單純——這是個靜態站(所有邏輯跑在瀏覽器,沒有後端 server),免費額度夠、CDN 內建、自動配 SSL、接 custom domain 不收錢。Firebase Hosting 這幾項都滿足,而且我這個專案本來就用了 Firebase(之後要存玩家進度),同一個專案順手就部署,不用多開帳號。
| 需求 | Firebase Hosting | GitHub Pages | Vercel |
|---|---|---|---|
| 靜態站託管 | ✓ | ✓ | ✓ |
| 自動 SSL | ✓ | ✓ | ✓ |
| custom domain 免費 | ✓ | ✓ | ✓ |
| 全球 CDN | ✓ | △(Fastly) | ✓ |
| 同專案接 Firestore/Auth | ✓ | ✗ | △ |
| SPA rewrite 設定 | ✓(一行 config) | △(要 404 hack) | ✓ |
🔧 部署流程:四個指令的事
先說好消息:把東西推上去,真的只是四個指令。
前置依賴很少:
| 依賴 | 最低版本 | 驗證 |
|---|---|---|
| Node | 20+ | node --version |
| Firebase CLI | 13+ | firebase --version |
| Firebase 帳號 | — | firebase projects:list |
15.9.1、Node v22.12.0。
整段部署指令塞在一起,給 Claude(或你自己)一次複製:
# 1. 確認登入 + 專案綁定
firebase projects:list
cat .firebaserc # 看 default project 是哪個
# 2. build 出靜態產物
npm run build # 我的 build script 是 tsc -b && vite build
# 3. 部署 hosting
firebase deploy --only hosting
# 4. 驗證預設網址(下一節細講)
curl -sS -o /dev/null -w "%{http_code}" https://<project>.web.app
firebase deploy --only hosting 跑完,真實輸出長這樣:
=== Deploying to 'battleflag-79afd'...
i deploying hosting
i hosting[battleflag-79afd]: beginning deploy...
i hosting[battleflag-79afd]: found 8 files in dist
i hosting: uploading new files [0/3] (0%)
i hosting: upload complete
✔ hosting[battleflag-79afd]: file upload complete
i hosting[battleflag-79afd]: version finalized
✔ hosting[battleflag-79afd]: release complete
✔ Deploy complete!
Hosting URL: https://battleflag-79afd.web.app
看到 Deploy complete! 跟 Hosting URL,預設網址就活了。但注意:這是 handshake,不是上線——稍後 round-trip 那節會解釋為什麼這行字不能信。
設定檔 firebase.json 的關鍵就一塊 hosting:
{
"hosting": {
"public": "dist",
"ignore": ["firebase.json", "/.*", "/node_modules/**"],
"rewrites": [{ "source": "**", "destination": "/index.html" }]
}
}
public: "dist" 指向 Vite build 的輸出目錄。那個 rewrites 是整篇第一個地雷,下一節單獨講。
💣 SPA 的隱藏地雷:深連結 404
如果你的站是單頁應用(SPA,用前端路由),少了 rewrites 這一段,首頁正常、深連結全爆。
原因是這樣:React Router(或任何前端 router)的路由是瀏覽器端跑的。使用者直接打 https://你的站/battle/c1-1,請求會先到 Firebase 的伺服器找 /battle/c1-1 這個檔案——但你的 dist/ 裡只有一個 index.html,沒有這個路徑的實體檔案,伺服器就回 404。前端 router 根本沒機會接手。
rewrites 那行的意思是:任何路徑都回 /index.html,讓瀏覽器載入 app 後,再由前端 router 解析網址、渲染對的畫面。
"rewrites": [{ "source": "**", "destination": "/index.html" }]
source: "**" 是萬用比對。漏掉它,你會在「首頁能開、分享給朋友的深連結打不開」這種詭異狀況上耗掉一個下午——而且首頁正常會讓你以為部署成功了。
🌐 接 custom domain:DNS 與 SSL
預設的 xxx.web.app 能動之後,接自己的網域分三步:
j7.yanchen.app。A record 指向兩個 Firebase IP,或子網域用 CNAME)。到你的 DNS 供應商加上去。子網域我用 CNAME ts → pixel-badminton.web.app 這種,指回 Firebase 的 .web.app。這三步指令面沒什麼好說的——點一點、貼一貼。難的是等待的判斷:你怎麼知道它好了?
⚠️ 如果你的 DNS 在 Cloudflare:CNAME 一定要「DNS only」(灰雲)
這是我這輪實際踩到、卡掉一截時間的雷,單獨拉出來講。Cloudflare 每一筆紀錄旁邊有個雲朵圖示,分兩種狀態:
- Proxied(橘雲):流量先經過 Cloudflare 的反向代理(CDN、防護、它自己的憑證)。
- DNS only(灰雲):Cloudflare 只當純 DNS 解析,請求直接打到 CNAME 指向的目標。
設定要點(以子網域 ts.yanchen.app 為例):
| 欄位 | 值 |
|---|---|
| Type | CNAME |
| Name | ts(只填子網域名,不含 .yanchen.app) |
| Target | pixel-badminton.web.app(你的 Firebase 站台) |
| Proxy status | DNS only(灰雲) ← 關鍵 |
| TTL | Auto |
驗 SSL 憑證真的簽下來、而且涵蓋你的網域,用 openssl 看 SAN(Subject Alternative Name):
echo | openssl s_client -servername j7.yanchen.app \
-connect j7.yanchen.app:443 2>/dev/null \
| openssl x509 -noout -ext subjectAltName
回傳裡有一長串 DNS:...,只要找得到 DNS:j7.yanchen.app 就代表憑證涵蓋你的網域。
這裡有個會嚇到人的細節:那串 SAN 裡會有幾十個完全不相干的網域(別人的網站)。那不是出錯,是 Firebase Hosting 用共用憑證(multi-SAN),把很多客戶的網域包在同一張憑證裡。只要清單裡有你的,就對了,不用管其他。
還有一個更早的中間態:no alternative certificate subject name matches
在憑證 SAN 命中你的網域之前,會先經過一段「DNS 通了、但憑證還沒簽好」的空窗。這時 curl 你的網域會噴:
curl -sS -I https://ts.yanchen.app
# SSL: no alternative certificate subject name matches target host name 'ts.yanchen.app'
第一次看到很容易誤判成「SSL 壞了」或「設定錯了」。其實這是正常的中間態:你的 CNAME 已經生效(TLS 握手能連上、拿得到一張憑證),只是 Firebase 還沒把你的網域簽進那張 Let's Encrypt 共用憑證的 SAN 清單裡。Firebase 會說「最多 24 小時」,實務上常是 10–60 分鐘。
怎麼跟「真的壞了」區分:這個錯誤代表 DNS 跟 TLS 那層都通了(不然連憑證都拿不到),純粹在等簽發。對策一樣是等——掛一個背景迴圈每 90 秒 curl 一次,等它從 no-cert-match 變成正常回應,再進到下面講的 404 收斂階段。
整條時間線是:DNS 生效 → 憑證簽好前(no-cert-match)→ SAN 命中你的網域 → 邊緣節點還沒鋪好(404 flapping)→ 穩定 200。每一段都是「在等」,不是「壞了」。
🔥 最大的坑:已連結卻一直 404
這是讓我卡最久的地方,也是整篇最該記住的一段。
DNS 設好、Console 顯示「已連結」、openssl 也確認 SSL SAN 命中我的網域——按理說該上線了。結果 curl 我的網域:
curl -sS -o /dev/null -w "%{http_code}" https://j7.yanchen.app
# 一下 200,一下 404,反覆橫跳
我一開始的直覺是「DNS 沒生效」或「設定打錯」,差點回去翻 DNS 紀錄。但停下來想:憑證都簽好了(SSL 握手要先驗網域所有權,憑證在 = DNS 早就通了),怎麼可能是 DNS 問題?
真正的原因是:Firebase 的邊緣節點內容同步還沒收斂。憑證簽發跟「把你的 hosting 內容鋪到全球 CDN 邊緣節點並正確對應到這個網域」是兩件事。前者好了,後者可能還在跑。在收斂完成前,你打過去可能被導到還沒鋪好內容的節點 → 404;下一秒打到鋪好的節點 → 200。它會這樣 flap(橫跳)數小時,最久我看過要將近一天。
判斷準則很簡單:這種 flapping 不要當成 bug 去修。設定沒錯就別動,等。要區分「沒收斂」跟「真的壞了」,看一個東西——憑證 SAN。SAN 命中你的網域 = 設定正確,剩下的純粹是時間。SAN 都沒有你的網域 = 那才是真的要回去查 DNS / Console。
我的處理方式是排一個定時喚醒,每 20 分鐘自動 curl 一次,連續多次穩定 200 才算收斂——不守在螢幕前刷。
✅ round-trip 驗證:handshake 不算上線
這是我做任何部署都用的鐵律,借這篇講清楚。
handshake(握手級訊號)≠ 上線。 這些都只代表「沒在最外層掛掉」:
firebase deploy印Deploy complete!- Console 顯示「已連結」
systemctl status綠燈- 單次
curl回 200
| 驗證項 | handshake(不夠) | round-trip(才算) |
|---|---|---|
| 部署 | Deploy complete! | curl 連打 5 次都穩定 200 |
| HTTPS | Console「已連結」 | openssl 看到 SAN 命中網域 |
| 內容 | HTTP 200 | 回傳 HTML 的 是對的 |
| 可用性 | 上面全過 | 真開瀏覽器,點一下,畫面正常 |
# 1. 連打 5 次,全 200 才算穩(單次 200 在 flapping 期會騙你)
for i in 1 2 3 4 5; do
curl -sS -o /dev/null -w "%{http_code}\n" "https://j7.yanchen.app?cb=$(date +%s)-$i"
sleep 2
done
# try1..5: 全 200
# 2. title 對不對(確認是我的站,不是 Firebase 預設頁)
curl -sS "https://j7.yanchen.app" | grep -oiE "<title>[^<]*</title>"
# <title>戰旗 J7 Reborn</title>
最後一步——真的開瀏覽器——是不能省的。我用瀏覽器自動化開站、選角、按「開始對戰」,看到棋盤、角色、手牌都正常渲染,才在報告裡寫「上線完成」。在這之前任何一個訊號為真,我都只會說「看起來通了」。
第二次喊「好了」要比第一次更嚴格,不是更隨便。因為第二次代表第一次驗錯了——該檢討的是驗法,不是再賭一次運氣。這次的 flapping 就是活教材:第一次看到 200 我差點收工,幸好沒有。
🤖 把流程交給 AI 的邊界
整條流程我交給 Claude Code 跑,這裡誠實講它幫上忙的地方跟我自己守的線。
AI 做得好的:指令編排(build → deploy → curl → openssl 一條龍)、解析 openssl 那串落落長的 SAN 找出我的網域、flapping 時自動排程定時重驗不用我守著、最後用瀏覽器自動化開站做 round-trip。重複而機械的部分,它比我穩、比我有耐心。
我自己守的線:
- DNS 紀錄與網域連結是我手動在後台做的——這類「改了會影響網域解析、出錯難回復」的設定,屬於不可逆的業務層,我不讓 AI 代下手。
- 「上線完成」這句話誰能說:必須 round-trip 全綠才算。AI 很容易看到
Deploy complete!就回報成功,這正是我把 round-trip 寫成它的硬性驗收條件的原因——讓它沒看到瀏覽器裡的畫面就不准說「好了」。
❓ 常見問題
Q:firebase deploy 印了 Deploy complete!,為什麼網站還是打不開?
A:Deploy complete! 只代表檔案上傳到 Firebase 完成(handshake),不代表你打的那個網址已經能正確回應。預設的 web.app 網址通常會很快活;但如果你打的是剛接好的 custom domain,可能還在邊緣節點內容同步中,會 flapping。先 curl 預設 web.app 網址確認部署本身 OK,再分開看 custom domain。
Q:custom domain 顯示「已連結」但一直 404,是我設定錯了嗎?
A:先用 openssl 看 SSL 憑證 SAN 有沒有命中你的網域。有命中 = 設定正確,404 是 Firebase 邊緣內容同步還沒收斂,這會 flap 數小時到近一天,不要去動設定,等就好。SAN 沒命中你的網域 = 那才是真的設定/DNS 問題,回去查 DNS 紀錄跟 Console 的網域狀態。
Q:SSL 憑證的 SAN 裡有一堆別人的網域,是被駭了嗎?
A:不是。Firebase Hosting 用 multi-SAN 共用憑證,把很多客戶的網域包在同一張憑證裡,這是正常的成本優化做法。你只要確認清單裡有自己的網域 DNS:你的網域 就對了,其他幾十個不相干的網域不用管。
Q:我的站首頁能開,但分享出去的深連結(像 /battle/c1-1)打不開?
A:你少了 SPA rewrite。在 firebase.json 的 hosting 加 "rewrites": [{ "source": "**", "destination": "/index.html" }],讓所有路徑都回 index.html,由前端 router 接手解析。重 deploy 一次即可。
Q:怎麼知道部署「真的」成功,而不是看起來成功?
A:做 round-trip,不要只看 handshake。最低標準:curl 連打數次都穩定 200(單次會被 flapping 騙)+ 回傳 HTML 的 是你的站 + 真的開瀏覽器點一下確認畫面正常。三者皆過才算上線。
🔗 延伸資源
- Firebase Hosting 官方文件 — Custom domain — DNS 紀錄與 SSL 自動簽發的官方說明
- Firebase Hosting — 設定行為(rewrites) — SPA rewrite 的完整 config 選項
- Claude Code Hook 怎麼用?git push 後自動驗 GitHub Actions 部署 — 同樣是「把部署驗證自動化」的思路,這篇用 hook 驗 CI,可搭配看
- MDN — Single-page applications — 為什麼 SPA 需要伺服器端 rewrite 的底層原因