- Published on
一天 700 個訪客之後,我把 Analytics 搬回家裡的 NAS
- Authors
-
-
- Name
- Alex Yu
-
前陣子在 Threads 發了部落格的第一篇推廣文,24 小時湧進 700 個訪客——對一個平常一天個位數訪客的部落格來說是暴擊等級。
開心不到一天,就去翻了 Vercel Web Analytics 的方案限制。三件事看完就決定要搬:
- UTM 參數(分辨訪客從哪個平台來的標記)Hobby 沒有,開 Pro 也還沒有,要再加 Web Analytics Plus,每個 team 每月 $10
- 資料只留 1 個月:Hobby 的 reporting window 就這麼長
- 免費額度 50,000 events/月,超過給 3 天寬限期就停止收集,而且額度是所有 project 共用
前兩件事本來就沒得談,第三件是那天的高峰讓我第一次認真去看那條線——一個免費額度,撞到就是數據直接斷掉。
家裡就有一台 Unraid NAS 24 小時開著。那就自己架。
選擇:為什麼是 Umami
比較過幾個 open source 的選項,決定的過程很快:
- Plausible CE:功能接近,但自架要跑 ClickHouse,對 NAS 太重
- GoatCounter:最輕,但沒有 custom events——之後 side project 要追蹤 landing page 行為就卡死
- Umami:一個 Node app 加一個 Postgres,介面像 Vercel Analytics,UTM 和 custom events 都免費
有一點要先說清楚:Umami 官網上的收費方案是他們代管的 Cloud 版。自架版完全免費、沒有任何事件數限制,我一開始也被官網價目表誤導了一下。
架構:部落格不動,只搬數據
讀者瀏覽器
├─ 讀網頁 → Vercel(部落格本體,完全不動)
└─ 送瀏覽事件 → analytics.example.com
↓ Cloudflare Tunnel
家裡的 Unraid NAS(Umami + Postgres)(下面所有 example.com 都是示意,換成你自己的網域。)
兩條線互相獨立:NAS 掛了,部落格照常運作,只是那段時間的數據沒記到。
對外靠 Cloudflare Tunnel——由 NAS 裡的 cloudflared 主動連出去 Cloudflare,家裡不開任何 port、對外 IP 不曝光。攻擊者查 DNS 只會看到 Cloudflare 的 IP,這對家用網路是關鍵:最怕的就是家裡 IP 被拿到後直接灌爆對外頻寬。
Unraid 上架 Umami
裝 Compose Manager Plus plugin(Apps 搜 Compose Manager,作者 mstrhakr),建一個 stack:
services:
umami:
image: ghcr.io/umami-software/umami:postgresql-latest
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://umami:<密碼>@db:5432/umami
DATABASE_TYPE: postgresql
APP_SECRET: <隨機字串>
depends_on:
db:
condition: service_healthy # ← 等 DB 健康檢查過了才啟動,省掉啟動順序問題
restart: always
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: <密碼> # ← 必須跟 DATABASE_URL 裡的一字不差
volumes:
- /mnt/user/appdata/umami/db:/var/lib/postgresql/data
restart: always
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U umami']
interval: 5s看起來很簡單。但我在這個 20 行的 compose 上連踩了兩個坑。
坑一:base64 密碼放進 URL 會爆
一開始用 openssl rand -base64 24 產密碼,結果 base64 會產生 /、+、=——這些字元放進 DATABASE_URL 這種 URL 格式會被解析壞掉(/ 在 URL 裡有結構意義)。
# ❌ 產出來的密碼可能含 / + =,塞進 URL 就爆
openssl rand -base64 24
# ✅ hex 只有 0-9a-f,放哪裡都安全
openssl rand -hex 24坑二:Postgres 的環境變數只在「第一次」有效
改完密碼重啟,還是一直 password authentication failed for user "umami"。log 有個很迷惑的地方:
✓ Database connection successful.
✗ Raw query failed. Code: `28P01`.
Message: `password authentication failed for user "umami"`連線成功、驗證失敗——因為 Postgres 的 POSTGRES_PASSWORD 只在資料目錄是空的、第一次初始化時才會生效。第一次啟動時我的 compose 裡有一個欄位忘了換掉佔位文字,資料庫就用錯誤密碼初始化了;之後不管怎麼改環境變數重啟都沒用。
解法是把資料目錄整個清掉重新初始化(反正還沒有資料):
rm -rf /mnt/user/appdata/umami/db
# 再 Compose Up,讓它用正確的密碼重新 initdb順帶一提,起來之後的預設登入是 admin / umami——我拿 umami / umami 試了好幾次。
DNS:從 Route 53 搬到 Cloudflare
Cloudflare Tunnel 要求網域託管在 Cloudflare 的 DNS 上,所以把 example.com 的 DNS 從 AWS Route 53 搬過去。域名的「註冊商」跟「DNS 託管」是兩回事——域名繼續留在 AWS 註冊,只把 nameserver(負責回答「這個網域指向哪」的伺服器)換成 Cloudflare 的。
流程:Cloudflare「新增網域」→ 選「連接網域」→ 它自動掃描現有紀錄 → 對照 Route 53 補漏 → 回 AWS 的 Registered domains 換 nameserver。搬完 Route 53 的 hosted zone 可以刪掉,順便省下每月 $0.50。
這裡有一個必踩不可的設定:指向 Vercel 的紀錄要設成灰色雲朵(僅 DNS),不要開橘色的 Proxy。開了等於 Cloudflare CDN 疊在 Vercel CDN 前面,SSL 模式和快取行為都會打架。驗證方式很簡單:
dig NS example.com +short # 應該回 Cloudflare 的 nameserver
curl -sI https://example.com | grep -i server
# server: Vercel ← 灰雲正確;如果回 cloudflare 就是疊到了
curl -sI https://analytics.example.com | grep -i server
# server: cloudflare ← 這條反過來,走 tunnel 就該是 Cloudflare搬完 DNS,到 Zero Trust 後台幫 tunnel 加一條路由:analytics.example.com → http://<NAS內網IP>:3000,對外就通了。
把追蹤碼掛上部落格
部落格是 Astro,追蹤碼放在 BaseLayout.astro 的 <head> 裡。第一版就四行:
<!-- ❌ 看起來沒問題,但接下來兩個坑都在這四行上面 -->
<script
defer
src="https://analytics.example.com/script.js"
data-website-id="<website-id>"></script>坑三:裝了之後,自己看不到自己
部署完,開 Umami 的 Realtime——沒有任何流量。
先確認 pipeline 有沒有通。Umami 的收數 endpoint 可以直接用 curl 模擬一筆瀏覽事件:
curl -X POST https://analytics.example.com/api/send \
-H "Content-Type: application/json" \
-H "User-Agent: Mozilla/5.0 ..." \ # ← 要帶瀏覽器 UA,curl 預設 UA 會被當 bot 丟掉
-d '{"type":"event","payload":{"website":"<website-id>","hostname":"example.com","url":"/test","language":"zh-TW","screen":"1920x1080"}}'回 200、Realtime 看到這筆——伺服器端沒問題。curl 打得通、瀏覽器不行,這個組合把嫌疑範圍縮到只剩瀏覽器才有的東西。
一開始懷疑廣告攔截器(analytics. 子網域配 script.js 確實正中 EasyPrivacy 清單),但攔截器解釋不了「所有訪客都掛零」——不可能人人都裝。開 DevTools 一看,答案在 Console:script 被 CSP(Content-Security-Policy) 擋掉了。
我的部落格模板本來就設了 CSP,script-src 白名單裡躺著一行 analytics.umami.is——那是模板預設給 Umami Cloud 的網域。我自架的 analytics.example.com 不在名單上,瀏覽器一律拒載:
// vercel.json
// ❌ 模板預設:白名單是 Umami Cloud 的網域,自架版載不進來
"script-src 'self' ... analytics.umami.is ..."
// ✅ 換成自己的 analytics 網域
"script-src 'self' ... analytics.example.com ..."curl 之所以打得通,是因為 CSP 是瀏覽器執行的政策,curl 根本不理它——這也是這個坑陰險的地方:你用 curl 驗證整條 pipeline 都是綠燈,真實流量卻是零。
坑四:Astro 把我的 script 屬性吃掉了
CSP 白名單改完,以為就結束了。後來想順手開 Umami 的 Core Web Vitals 收集,加一個屬性而已:
data-performance="true" <!-- ← Umami v3.1.0+,從訪客瀏覽器收 Core Web Vitals -->去 build 出來的 HTML 確認,才發現我寫的那幾行從來沒有照原樣出現在頁面上:
<!-- ❌ dist/index.html:Astro 把整個 tag 換成一支打包後的 module -->
<script type="module" src="/_astro/BaseLayout.astro_astro_type_script_index_0_lang.C7oMJtep.js"></script>// 而那支檔案裡面只有一行
import 'https://analytics.example.com/script.js'defer、data-website-id、data-performance 全部不見。而且它變成了 module——module 裡沒有 document.currentScript,tracker 讀不到自己那個 script 元素,也就讀不到任何 data-* 設定。
這個坑最陰險的地方是它沒有壞得乾脆:pageview 還是有數字(事件靠 hostname 被對回同一個網站),只有 Web Vitals 那頁從頭到尾空白,資料庫裡一筆 performance 紀錄都沒有。要不是我去開那頁,大概到現在都不知道。
後來回頭讀 tracker 的原始碼,新版本已經不那麼客氣了:
// script.js(minify 過的,變數名是編譯後的)
const { currentScript: u } = c
if (!u) return // ← 讀不到自己的 script 元素就直接掉頭現在踩同一個坑,是連 pageview 都不會有。
解法是 is:inline,明確叫 Astro 原樣輸出、不要碰:
<script
is:inline
defer
src="https://analytics.example.com/script.js"
data-website-id="<website-id>"
data-performance="true"></script>Astro 文件寫「有 src 以外的屬性就不會被處理」,但我在 Astro 6.4.6 實測不是這樣:defer 加三個 data-* 都在,照樣被打包成 module。第三方 script 一律加 is:inline,不要賭文件。
兩個坑合起來一句話:CSP 和 build 產物都只在瀏覽器端現形,curl 全綠不代表沒事。
驗收:兩套並跑一週,再拆掉舊的
自架的數字敢不敢用,要看它跟舊工具對不對得上。所以 Umami 上線後我沒馬上砍 Vercel Analytics,讓兩套並跑一週(7/19–7/26),再把兩邊的數字拉出來對:
| 指標 | Umami 相對 Vercel |
|---|---|
| Page views | −2% |
| Unique visitors | −12% |
pv 幾乎一樣,unique visitors 少 12%。這個落差的方向是合理的:Umami 的 script 掛在 analytics. 子網域,會被一部分廣告攔截器擋掉;Vercel 的走自家 /_vercel/insights 同源路徑,擋不到。
12% 我可以接受,就把舊的拆了:
// package.json
- "@vercel/speed-insights": "^2.0.0", // src/layouts/BaseLayout.astro
- <script defer src="/_vercel/insights/script.js"></script>
- <SpeedInsights />Core Web Vitals 交給 data-performance 接手,analytics 收斂成單軌。
至於攔截器,那是自架分析工具的宿命。有繞過的做法(script 改名、走主網域 proxy),我決定先不做——數據的用途是看趨勢和平台佔比,系統性低估 12% 不影響判斷。
重點整理
- Umami 自架版沒有事件數限制,官網價目表是 Cloud 版的,別被騙到
- 密碼要放進 URL 就用
openssl rand -hex;Postgres 的POSTGRES_*只在資料目錄空的時候生效,改密碼要清資料重新 initdb - Cloudflare Tunnel 讓 NAS 完全不開 port、IP 不曝光;指向 Vercel 的 DNS 紀錄記得用灰色雲朵
- curl 通不等於瀏覽器通:CSP 白名單漏了自己的網域、Astro 少了
is:inline,兩個都只在瀏覽器端現形 - 換掉舊 analytics 前先並跑一週對數字(pv 差 2%、unique 差 12%),才知道新的敢不敢用
參考資料
- Umami - Install with Docker
- Umami - Tracker configuration(
data-performance等 data 屬性) - Astro - Client-side scripts(script 處理與
is:inline) - Cloudflare Tunnel
- Vercel - Web Analytics 方案限制
- Vercel - DNS only records