前陣子在 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%),才知道新的敢不敢用

參考資料