diff --git a/public/banners/one-hostname-public-tracker-sso-dashboard.png b/public/banners/one-hostname-public-tracker-sso-dashboard.png
new file mode 100644
index 0000000..568a700
Binary files /dev/null and b/public/banners/one-hostname-public-tracker-sso-dashboard.png differ
diff --git a/public/og/one-hostname-public-tracker-sso-dashboard.png b/public/og/one-hostname-public-tracker-sso-dashboard.png
new file mode 100644
index 0000000..9c0c9ba
Binary files /dev/null and b/public/og/one-hostname-public-tracker-sso-dashboard.png differ
diff --git a/scripts/banner-gen/generate.mjs b/scripts/banner-gen/generate.mjs
index 0b45f78..a91f99c 100644
--- a/scripts/banner-gen/generate.mjs
+++ b/scripts/banner-gen/generate.mjs
@@ -1028,6 +1028,27 @@ BANNERS['jpa-version-field-lost-update'] = {
{ n: '4', label: '409 ✓' },
],
};
+BANNERS['one-hostname-public-tracker-sso-dashboard'] = {
+ titlebar: 'root@dsm — stats.hoelee.com · authentik outpost',
+ lines: [
+ { t: 'prompt', text: '$' }, { t: 'cmd', text: 'curl -sI https://stats.hoelee.com/script.js' },
+ { t: 'ok', text: '200 — the tracker stays public for every visitor' },
+ { t: 'prompt', text: '$' }, { t: 'cmd', text: 'curl -sI https://stats.hoelee.com/' },
+ { t: 'dim', text: '302 → auth.hoelee.com/if/flow/auth-stats/ (SSO)' },
+ { t: 'err', text: 'mode=forward_single → app paths 404 behind a "healthy" SSO chain' },
+ { t: 'hl', text: 'in proxy mode the outpost IS the reverse proxy' },
+ { t: 'prompt', text: '$' }, { t: 'cmd', text: 'skip_path_regex · ^/script\\.js$ ^/api/send ^/api/heartbeat$' },
+ { t: 'ok', text: 'mode=proxy + internal_host → one hostname, no second subdomain ✓' },
+ ],
+ flow: [
+ { n: '1', label: 'tracker public' },
+ { n: '2', label: 'dashboard gated' },
+ { n: '3', label: 'one hostname' },
+ { n: '4', label: 'skip paths' },
+ { n: '5', label: 'verified ✓' },
+ ],
+};
+
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
diff --git a/scripts/og-gen/generate.mjs b/scripts/og-gen/generate.mjs
index 46db8eb..89e1cf2 100644
--- a/scripts/og-gen/generate.mjs
+++ b/scripts/og-gen/generate.mjs
@@ -316,6 +316,11 @@ TERMINALS['jpa-version-field-lost-update'] = `
@Version → UPDATE … WHERE version = 3
→ 409 Conflict, not a lost update ✓
`;
+TERMINALS['one-hostname-public-tracker-sso-dashboard'] = `
+ $curl -sI https://stats.hoelee.com/script.js | head -1→ 200
+ mode=forward_single: SSO 302 ✓ · every app path 404
+ $mode=proxy + internal_host=http://umami:3000→ dashboard 200 ✓
`;
+
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
if (!existsSync(postPath)) {
diff --git a/src/content/posts/one-hostname-public-tracker-sso-dashboard.md b/src/content/posts/one-hostname-public-tracker-sso-dashboard.md
new file mode 100644
index 0000000..36c488d
--- /dev/null
+++ b/src/content/posts/one-hostname-public-tracker-sso-dashboard.md
@@ -0,0 +1,280 @@
+---
+title: "One Hostname for a Public Tracker and an SSO-Gated Dashboard"
+description: "A web-analytics tracker has to be public and its dashboard must not be. How I put both on one hostname with an authentik proxy provider and path-based skip rules."
+pubDate: 2026-09-30
+category: devops
+tags: ["umami", "authentik", "sso", "nginx", "docker", "privacy"]
+ogImage: "/og/one-hostname-public-tracker-sso-dashboard.png"
+banner: "/banners/one-hostname-public-tracker-sso-dashboard.png"
+draft: false
+---
+
+Every analytics tool has the same awkward shape. The half that *collects* data
+has to be reachable by a stranger's browser on every page load. The half that
+*shows* the data must be reachable by nobody but me.
+
+That normally means two hostnames: a public ingest endpoint and a private
+dashboard. I did not want two hostnames. I wanted one DNS record, one
+certificate, one thing to remember — and a dashboard I could open from a hotel
+wifi in Kuala Lumpur without publishing a login page to the internet.
+
+This is the shape that ended up working, and the two traps that made a
+perfectly healthy-looking configuration serve 404s for half an hour.
+
+## The constraint list
+
+- **The tracker must be public.** `script.js` and the two ingest endpoints are
+ fetched by every visitor's browser. If they are behind auth, you collect
+ nothing and you will not notice for days.
+- **The dashboard must not be public.** It shows every visitor's country, city,
+ referrer and page path. A default admin login on a public URL is a gift to
+ whoever finds the subdomain in a certificate-transparency log.
+- **No wildcard DNS.** `*.hoelee.com` does not resolve, so every hostname is an
+ explicit DNS record plus a certificate. A second hostname is real work, and it
+ doubles the surface I have to keep patched.
+- **This hostname does not go through Cloudflare.** It is a direct A record to my
+ router, so there is no WAF, no bot protection and no `CF-Connecting-IP` header
+ to lean on. Everything below has to work with plain nginx headers.
+
+## Attempt 1 — two hostnames
+
+The conventional split is what PostHog and Sentry do: `i.posthog.com` for ingest,
+`app.posthog.com` for the UI. That is a real requirement when ingest is served
+from a CDN at thousands of requests per second and the app is a stateful
+database client. My blog gets a handful of visits a day. I was copying an
+architecture that solves a problem I do not have, and paying for it with a second
+DNS record, a second certificate and a second thing to break.
+
+Two hostnames is the *scale* answer, not the *requirement*. One hostname can
+carry both, split by path.
+
+## Attempt 2 — nginx basic auth (this one does not work, and here is why)
+
+My first instinct was the cheapest possible gate: an nginx container in front of
+the analytics app, with `auth_basic` on everything except the three tracker
+paths. It is four lines of config and I have used it before.
+
+It breaks the dashboard completely, and the failure is confusing enough to be
+worth writing down.
+
+The app's own front end talks to its own API with a bearer token:
+
+```
+GET /api/websites HTTP/1.1
+Authorization: Bearer
+```
+
+A browser sends **one** `Authorization` header per request. When the front end
+adds its bearer token, that header replaces the basic-auth credentials — so the
+gate reads a bearer token where it expects a base64 user:password pair, decides
+the request is unauthenticated, and returns 401. The dashboard shell loads
+(that request carries basic auth), and then every single API call fails. You get
+a UI that renders its layout and shows nothing, which reads as "the analytics
+tool is broken" rather than "my gate is fighting my app".
+
+Cookie-based auth does not have this problem, because the credentials live in a
+`Cookie` header that the app's own tokens never touch. So: cookie auth, or no
+gate.
+
+## Attempt 3 — an nginx allowlist gate (works, but the dashboard goes LAN-only)
+
+The next version dropped basic auth entirely and made the gate a pure allowlist:
+`/script.js`, `/api/send` and `/api/heartbeat` pass through, everything else gets
+403. That is genuinely safe — the tracker is public, the dashboard is not
+reachable at all — and it is a fine permanent answer if you only ever look at
+your stats from inside your own network.
+
+I wanted to see the dashboard from anywhere, so this became the fallback rather
+than the destination. I stopped the container and kept it, which turned out to be
+the right call later: rollback was one `docker start` and one config line.
+
+## Attempt 4 — authentik proxy provider with path-based skip rules
+
+I already run authentik for single sign-on across about twenty self-hosted apps.
+Most of them are gated the same way: the reverse proxy forwards the request to
+authentik's embedded outpost, the outpost checks for a session cookie, and an
+unauthenticated visitor gets a 302 to the login flow and back.
+
+The proxy provider has a field built for exactly this problem:
+`skip_path_regex`. One regex per line. Any path that matches is forwarded
+straight to the app with **no authentication at all**; anything that does not
+match is bounced to the SSO login. So the split stops being an nginx concern and
+becomes an application-level policy.
+
+```
+mode: proxy
+external_host: https://stats.hoelee.com
+internal_host: http://umami:3000
+skip_path_regex: ^/script\.js$
+ ^/api/send
+ ^/api/heartbeat$
+ ^/mcp(/|$)
+```
+
+That is the whole policy: the tracker's asset, its ingest endpoint, its health
+endpoint and its MCP endpoint are public; every other path on that hostname
+requires SSO.
+
+The nginx vhost then points at the outpost instead of at the app:
+
+```nginx
+location / {
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_pass http://localhost:10000; # the authentik outpost
+}
+```
+
+## Trap 1 — the provider mode that 404s the whole app behind a "working" SSO
+
+I built the new provider by cloning one that already worked in my instance. That
+is normally the fastest and safest way to add an app, and it is exactly how I got
+this wrong.
+
+The provider I copied used `mode: forward_single` with an empty `internal_host`.
+That is correct for apps that handle their own login (a browser app, a monitoring
+UI behind its own session) — the outpost only has to answer "is this visitor
+allowed?", and the app is reached by some other route. But it means the outpost
+has **no upstream to proxy to**. When the app you are gating has no login of its
+own — a self-hosted analytics dashboard, for instance — the outpost *is* the
+proxy, and with an empty `internal_host` it has nowhere to send the request.
+
+The symptom is nasty because the auth half looks perfect:
+
+```
+$ curl -sI https://stats.hoelee.com/ | head -1
+HTTP/2 302 # → auth.hoelee.com/if/flow/auth-stats/
+$ curl -s -o /dev/null -w '%{http_code}' https://stats.hoelee.com/script.js
+404 # ← but the app is not there
+```
+
+302 to the login flow, the branded login page renders, the flow completes — and
+every application path returns 404. Two things made the diagnosis fast once I
+looked for them:
+
+1. **The `x-powered-by` header says which hop answered.** The 404 carried
+ `x-powered-by: authentik`, which proves the outpost produced it and the app
+ never saw the request. Without that header I would have been debugging the
+ application's routing.
+2. **Compare against a provider that works.** The same anonymous request against
+ an app that is known good ends at the login flow with a 200. Mine ended at
+ 404 — same shape, different last hop.
+
+The fix is one field pair: `mode: proxy` with a real `internal_host`.
+
+```
+mode: proxy # not forward_single
+internal_host: http://umami:3000 # the outpost proxies to the container
+```
+
+`forward_single` = "the app is somewhere else, just check the visitor".
+`proxy` = "you are the reverse proxy, send the request to `internal_host`".
+Pick by asking who serves the response.
+
+## Trap 2 — the outpost needs a minute, and it lies convincingly
+
+After changing a provider, the embedded outpost takes one to two minutes to pick
+up the new configuration. During that window the hostname answers like this:
+
+```
+302 → /flows/-/default/authentication/ # a default flow, slug "-"
+404 # on every app path
+```
+
+That is not the new configuration failing — it is the old configuration not yet
+being replaced. I lost time on this twice: once concluding the wiring was broken,
+once concluding the mode change had not taken effect. Now I change one thing,
+wait ninety seconds, and only then judge the result. The same applies to
+attaching a new provider to the outpost: the provider exists in the API
+immediately, and starts answering requests a minute later.
+
+## What I actually verified, and from where
+
+Internal checks are worthless for this class of change. My instance has two
+ingress layers on some hostnames (an nginx vhost and a tunnel rule straight to a
+container), so a request from inside the LAN can pass while the public path
+bypasses the gate entirely. Everything below was measured from the public
+internet, on the real hostname.
+
+| Check | Result |
+|---|---|
+| `GET /` (anonymous) | 302 → outpost → the app's own login flow → **200** |
+| `GET /login`, `/api/websites` | 302, gated |
+| `GET /script.js` | **200** — the tracker still loads for visitors |
+| `GET /api/heartbeat` | **200** |
+| `POST /api/send` with a bogus site id | **400 from the app**, not 403 from a gate — proof the request reaches the app |
+| `POST /mcp` with the API key | **200**, `text/event-stream` |
+| `POST /mcp` without the key | **401** `Missing bearer API key` — the app's own auth, not the SSO gate |
+| A real pageview | recorded with `country=MY region=MY-07 city=George Town`, browser, OS and referrer intact |
+
+That last row is the one that matters. Because this hostname does not go through
+Cloudflare, the visitor's IP has to survive two proxy hops via
+`X-Forwarded-For`, and the app has to be told to read that header
+(`CLIENT_IP_HEADER=x-forwarded-for`) instead of the Cloudflare one. A pageview
+counter that increments while every visitor is attributed to a container IP
+looks like success and is useless. Recording the right city is the proof.
+
+## What I would do differently
+
+- **Keep the previous gate stopped, not deleted.** The nginx allowlist container
+ from attempt 3 is still on disk, stopped. Rollback is `docker start` plus one
+ line in the vhost — thirty seconds, versus rebuilding it from memory under
+ pressure.
+- **Rotate the app's default credentials before the hostname is reachable, not
+ after.** I had a public URL and a default `admin`/`admin`-style login live at
+ the same time for part of an afternoon, and a new subdomain shows up in
+ certificate-transparency logs within hours.
+- **Change one field, then wait.** Two of my three wrong conclusions in this
+ session came from reading a result before the system had finished applying it.
+- **Test the app's own login flow too.** SSO passing does not mean the app works:
+ the gate can be perfect and the session cookie it sets can still be rejected
+ downstream. I verified the tracker path with curl and the dashboard with a real
+ browser, and only then called it done.
+
+## The trade-offs, honestly
+
+- **Double login.** The analytics app has no OIDC support, so SSO grants access
+ to the route and the app still asks for its own username and password. One
+ extra click, once per browser. Per-account two-factor authentication is
+ available inside the app, which is the part that actually protects the data.
+- **The dashboard is as available as the outpost.** If authentik is down, the
+ dashboard is down. The tracker fails silently at the same time, which is
+ survivable: pages still load, the beacon just does not land.
+- **I kept one break-glass path.** The app still publishes a port on the LAN,
+ which bypasses SSO entirely. It is not reachable from the internet (verified
+ from an external host), it still requires the app's own credentials, and I
+ would rather have it than be locked out of my own analytics by an SSO
+ misconfiguration.
+
+## The result
+
+One hostname, one DNS record, one certificate. No second subdomain, no extra
+container — the gate is a feature of the SSO instance I was already running. The
+tracker is reachable by every visitor's browser, the dashboard is reachable by
+me from anywhere, and an anonymous visitor gets a login page instead of a
+dashboard.
+
+The measurable version: three tracker paths and the MCP endpoint answer
+correctly to anonymous requests, every other path on the hostname returns 302 to
+SSO, and pageviews land with the visitor's real city and referrer — which is the
+only reason the whole exercise was worth doing.
+
+If you are self-hosting analytics (or anything with a public ingest half), try
+one hostname with path-based skip rules before you add a second DNS record. The
+two-hostname split is an architecture for companies whose ingest traffic pays for
+a CDN. Yours probably is not.
+
+## Want this for your business?
+
+If you want to know what your website is actually doing without handing your
+visitors' data to an ad network — or you have an internal tool that should never
+be reachable from the internet — I set up self-hosted, cookie-free analytics and
+SSO gates like the one in this post: one hostname, no consent banner, no
+third-party script phoning home from every page.
+
+**WhatsApp: [+60 12-797 2969](https://wa.me/60127972969)** · **Email: [me@hoelee.com](mailto:me@hoelee.com?subject=Self-hosted%20analytics%20and%20SSO)** · **[hoelee.com](https://hoelee.com)**
+
+Website design and development is my main line of work; server hardening and
+self-hosted infrastructure is the other half of it.
diff --git a/src/content/posts/zh/one-hostname-public-tracker-sso-dashboard.md b/src/content/posts/zh/one-hostname-public-tracker-sso-dashboard.md
new file mode 100644
index 0000000..d089d20
--- /dev/null
+++ b/src/content/posts/zh/one-hostname-public-tracker-sso-dashboard.md
@@ -0,0 +1,170 @@
+---
+title: "公开的埋点与要 SSO 的仪表盘,怎么共用一个域名"
+description: "埋点必须公开,仪表盘绝不能公开。我用 authentik 的 proxy provider 加路径放行规则,把两者放在同一个域名上——不用第二个子域,也不用第二个证书。"
+pubDate: 2026-09-30
+category: devops
+tags: ["umami", "authentik", "sso", "nginx", "docker", "privacy"]
+ogImage: "/og/one-hostname-public-tracker-sso-dashboard.png"
+banner: "/banners/one-hostname-public-tracker-sso-dashboard.png"
+draft: false
+---
+
+几乎每个统计工具都长成一个尴尬的形状:负责**收集**数据的那一半,必须让陌生人的浏览器在每次打开页面时都能访问;负责**展示**数据的那一半,必须只有我能访问。
+
+通常这意味着两个域名:一个公开的埋点入口,一个私有的仪表盘。我不想要两个域名。我只想要一条 DNS 记录、一张证书、一件要记住的事——同时能在吉隆坡某家酒店的 wifi 上打开仪表盘,而不用把一个登录页发布到公网。
+
+下面就是最后跑通的形状,以及两个让「看起来完全正常」的配置连续半小时返回 404 的坑。
+
+## 约束清单
+
+- **埋点必须公开。** `script.js` 和两个收集端点由每个访客的浏览器抓取。如果它们被鉴权挡住,你就什么都收不到,而且好几天都不会发现。
+- **仪表盘绝不能公开。** 它显示每个访客的国家、城市、来源和页面路径。一个默认管理员登录挂在公网 URL 上,就是送给任何在证书透明度日志里翻到你子域的人。
+- **没有泛解析 DNS。** `*.hoelee.com` 不解析,所以每个域名都是一条显式 DNS 记录加一张证书。第二个域名是实打实的工作量,还让我要维护的攻击面翻倍。
+- **这个域名不走 Cloudflare。** 它是一条直指我家路由器的 A 记录,所以没有 WAF、没有 bot 防护,也没有 `CF-Connecting-IP` 头可以依赖。下面所有东西都只能靠普通 nginx 头工作。
+
+## 方案一:两个域名
+
+业界常见的拆法是 PostHog 和 Sentry 的做法:`i.posthog.com` 收数据,`app.posthog.com` 看数据。当收集端由 CDN 承载、每秒几千请求、而应用端是个有状态数据库客户端时,这是真需求。我的博客一天只有个位数访问。我是在抄一个解决「我没有的问题」的架构,代价是第二条 DNS 记录、第二张证书、第二个会坏的地方。
+
+两个域名是**规模**的答案,不是**需求**。一个域名就能同时承载两者,按路径分开就行。
+
+## 方案二:nginx basic auth(这个方案行不通,原因值得记下来)
+
+我第一反应是用最便宜的挡法:在统计应用前面放一个 nginx 容器,除了三个埋点路径以外全挂 `auth_basic`。四行配置,我以前用过。
+
+它会把仪表盘彻底弄坏,而且坏的方式足够让人困惑,值得写下来。
+
+应用自己的前端会用 bearer token 调自己的 API:
+
+```
+GET /api/websites HTTP/1.1
+Authorization: Bearer
+```
+
+浏览器每个请求只发**一个** `Authorization` 头。当前端加上它的 bearer token 时,这个头就**顶掉**了 basic auth 的凭据——于是网关读到的是一个 bearer token,却期望它是 base64 的 user:password,判定为未鉴权,返回 401。仪表盘的外壳能加载(那个请求带着 basic auth),然后每一个 API 调用都失败。你得到的是一个渲染出框架、里面什么都没有的界面,读起来像「统计工具坏了」,而不是「我的网关在跟我的应用打架」。
+
+基于 cookie 的鉴权没有这个问题,因为凭据放在 `Cookie` 头里,应用自己的 token 永远不会碰它。所以:要么 cookie 鉴权,要么不要网关。
+
+## 方案三:nginx 白名单网关(能用,但仪表盘变成只能内网访问)
+
+下一版彻底去掉 basic auth,把网关做成纯白名单:`/script.js`、`/api/send`、`/api/heartbeat` 放行,其余一律 403。这确实安全——埋点公开,仪表盘根本不可达——如果你只在自己网络里看数据,这完全可以作为最终方案。
+
+但我想在任何地方都能看仪表盘,所以它变成了退路而不是终点。我把容器停掉但保留着,后来证明这个决定是对的:回滚只需要一次 `docker start` 加一行配置。
+
+## 方案四:authentik proxy provider + 按路径放行
+
+我本来就在用 authentik 给二十来个自托管应用做单点登录。它们大多用同一种方式挡:反向代理把请求转给 authentik 的 embedded outpost,outpost 检查会话 cookie,未登录的访客被 302 送到登录流程,登录后再送回来。
+
+proxy provider 上有一个正好为这个问题准备的字段:`skip_path_regex`。一行一条正则。**任何匹配的路径都会完全不鉴权**直接转发给应用;不匹配的一律弹到 SSO 登录。于是「哪部分公开」不再是 nginx 的事,而变成了应用层的策略。
+
+```
+mode: proxy
+external_host: https://stats.hoelee.com
+internal_host: http://umami:3000
+skip_path_regex: ^/script\.js$
+ ^/api/send
+ ^/api/heartbeat$
+ ^/mcp(/|$)
+```
+
+这就是全部策略:埋点脚本、收集端点、健康检查端点和 MCP 端点公开;这个域名上的其他所有路径都要过 SSO。
+
+nginx 的 vhost 则从指向应用改成指向 outpost:
+
+```nginx
+location / {
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_pass http://localhost:10000; # the authentik outpost
+}
+```
+
+## 坑一:一个 provider 模式,让「正常的 SSO」把整个应用变成 404
+
+我建新 provider 的方式是克隆一个已经能用的。通常这是加应用最快也最安全的做法,而这正是我踩坑的原因。
+
+我抄的那个 provider 用的是 `mode: forward_single`,`internal_host` 是空的。对于自带登录的应用(比如一个浏览器应用、一个自己有会话的监控界面),这是对的——outpost 只需要回答「这个访客允许吗」,应用由别的路径抵达。但它意味着 outpost **没有可代理的上游**。当你挡的应用自己没有登录(比如自托管的统计仪表盘),outpost **就是**那个反向代理,而 `internal_host` 为空时它无处可送。
+
+症状之所以难缠,是因为鉴权那一半看起来完美:
+
+```
+$ curl -sI https://stats.hoelee.com/ | head -1
+HTTP/2 302 # → auth.hoelee.com/if/flow/auth-stats/
+$ curl -s -o /dev/null -w '%{http_code}' https://stats.hoelee.com/script.js
+404 # ← 但应用不在这里
+```
+
+302 到登录流程、品牌登录页正常渲染、流程走完——而每一个应用路径都返回 404。一旦开始找,有两样东西让定位变得很快:
+
+1. **`x-powered-by` 头会告诉你哪一跳应答的。** 那个 404 带着 `x-powered-by: authentik`,证明它是 outpost 产生的,应用根本没收到请求。没有这个头,我就会去查应用的路由。
+2. **拿一个能用的 provider 对照。** 同样的匿名请求打到已知正常的应用,会停在登录流程并返回 200。我的是 404——形状一样,最后一跳不同。
+
+修复只是一对字段:`mode: proxy` 加一个真实的 `internal_host`。
+
+```
+mode: proxy # 不是 forward_single
+internal_host: http://umami:3000 # outpost 把请求代理到这个容器
+```
+
+`forward_single` = 「应用在别处,你只负责检查访客」。
+`proxy` = 「你就是反向代理,把请求送到 `internal_host`」。
+怎么选:问一句**谁来产生响应**。
+
+## 坑二:outpost 需要一分钟,而它会很有说服力地骗你
+
+改完 provider 之后,embedded outpost 需要一到两分钟才会拉到新配置。在这个窗口里,域名会这样应答:
+
+```
+302 → /flows/-/default/authentication/ # 一个默认流程,slug 是 "-"
+404 # 所有应用路径
+```
+
+这不是新配置失败,而是旧配置还没被替换。我在这上面浪费了两次时间:一次是判断接线坏了,一次是判断模式修改没生效。现在的做法是改一处、等九十秒、然后才判断结果。把新 provider 挂到 outpost 上也一样:provider 在 API 里立刻存在,但要一分钟后才开始应答请求。
+
+## 我实际验证了什么,以及从哪里验证
+
+这类改动用内网自测毫无意义。我的环境里有些域名有两层入口(一个 nginx vhost,加一条直连容器的隧道规则),所以从内网发的请求可以通过,而公网路径完全绕开网关。下面全部是从公网、用真实域名测的。
+
+| 检查 | 结果 |
+|---|---|
+| 匿名 `GET /` | 302 → outpost → 该应用自己的登录流程 → **200** |
+| `GET /login`、`/api/websites` | 302,被挡 |
+| `GET /script.js` | **200**——埋点脚本对访客仍然加载 |
+| `GET /api/heartbeat` | **200** |
+| 用假 site id `POST /api/send` | **应用返回 400**,不是网关返回 403——证明请求真的到了应用 |
+| 带 API key `POST /mcp` | **200**,`text/event-stream` |
+| 不带 key `POST /mcp` | **401** `Missing bearer API key`——应用自己的鉴权,不是 SSO 网关 |
+| 一次真实 pageview | 记录到 `country=MY region=MY-07 city=George Town`,浏览器、系统和来源都完整 |
+
+最后一行才是关键。因为这个域名不走 Cloudflare,访客 IP 必须靠 `X-Forwarded-For` 穿过两跳代理,并且要告诉应用去读这个头(`CLIENT_IP_HEADER=x-forwarded-for`)而不是读 Cloudflare 的那个。一个数字在涨、但所有访客都被归到容器 IP 上的统计,看起来像成功,其实毫无用处。**能记录到正确的城市才是证据。**
+
+## 我下次会怎么做
+
+- **把上一个网关停掉,而不是删掉。** 方案三那个 nginx 白名单容器还在磁盘上,处于停止状态。回滚是 `docker start` 加 vhost 里改一行——三十秒,而不是在压力下凭记忆重建它。
+- **在域名可达之前轮换应用的默认凭据,而不是之后。** 我有一个下午让公网 URL 和默认的管理员登录同时存在;而新子域几个小时内就会出现在证书透明度日志里。
+- **一次只改一个字段,然后等。** 这次会话里三个错误结论,有两个来自在系统还没应用完变更时就去读结果。
+- **也要测应用自己的登录流程。** SSO 通过不代表应用能用:网关可能完全正确,而它设下的会话 cookie 在下游被拒。我用 curl 验证埋点路径、用真实浏览器验证仪表盘,然后才敢说做完了。
+
+## 说清楚取舍
+
+- **双重登录。** 统计应用不支持 OIDC,所以 SSO 只解决了「能不能到这个路由」,应用仍会要它自己的用户名密码。每个浏览器多一次点击。真正保护数据的是应用内可以按账号开启的两步验证。
+- **仪表盘的可用性等于 outpost 的可用性。** authentik 挂了,仪表盘就挂了;同时埋点会静默失败,这还能接受:页面照常打开,只是数据没落库。
+- **我保留了一条后门。** 应用仍在局域网里发布一个端口,完全绕开 SSO。它从公网不可达(用外部主机验证过),仍然需要应用自己的凭据,但我宁可留着它,也不想因为一次 SSO 配置失误把自己锁在自己的统计之外。
+
+## 结果
+
+一个域名、一条 DNS 记录、一张证书。没有第二个子域,没有额外容器——网关就是我本来就在跑的 SSO 实例的一个功能。埋点对每个访客的浏览器可达,仪表盘对我随处可达,匿名访客看到的是登录页而不是仪表盘。
+
+可以量化的版本:三个埋点路径加 MCP 端点对匿名请求应答正确,域名上其余所有路径 302 到 SSO,pageview 带着访客真实城市和来源落库——这也是整件事值得做的唯一理由。
+
+如果你也在自托管统计(或任何有「公开收集端」的东西),先试试用一个域名加路径放行规则,再考虑加第二条 DNS 记录。两域名的拆法是给那些收集流量足以养一个 CDN 的公司用的架构。你的多半不是。
+
+## 需要为你的业务做这个吗?
+
+如果你想真正知道网站在发生什么,又不想把访客数据交给广告网络——或者你有一个绝不该从公网访问的内部工具——我可以帮你搭自托管、无 cookie 的统计,以及像这篇里那样的 SSO 网关:一个域名、不需要同意横幅、没有第三方脚本在每一页偷偷回连。
+
+**WhatsApp:[+60 12-797 2969](https://wa.me/60127972969)** · **邮箱:[me@hoelee.com](mailto:me@hoelee.com?subject=Self-hosted%20analytics%20and%20SSO)** · **[hoelee.com](https://hoelee.com)**
+
+网站设计与开发是我的主业;服务器加固与自托管基础设施是它的另一半。