--- title: "给自己改不了源码的应用装一扇前门" description: "我自托管阅读服务的门面,是一个中文登录框;而应用本身是客户端渲染的 SPA,我改不动。于是我在它前面加了一个容器:nginx、一个双语页面,和三行注入。" pubDate: 2025-06-24 updatedDate: 2026-10-06 category: devops blueprint: self-hosted-infrastructure blueprintLayer: L2-edge-proxy-runtime tags: ["nginx", "docker", "self-hosting", "seo", "branding"] ogImage: "/og/zh-putting-a-front-door-on-an-app-you-cant-modify.jpg" banner: "/banners/putting-a-front-door-on-an-app-you-cant-modify.jpg" draft: false dynamicCta: true --- 我的阅读服务,访客打开看到的第一样东西是一个中文登录框。没有说明这个站是什么,没有邀请码说明,没有品牌,链接分享出去也没有社交卡片,搜索引擎也没有任何值得收录的东西。就是一个登录弹窗,上面一首诗。 这个服务是我给家人和几位付费读者跑的,所以「新读者第一眼看到什么」不是装饰问题,它就是产品本身。而我改不了它——它背后是别人的代码:一个 hash 路由的 Vue 单页应用,界面不归我管,版本按别人的节奏发布。 **那么问题来了:怎么给一个自己改不了的自托管应用套上自己的首页、品牌和语言处理,既不 fork 它,也不做一个下次镜像更新就碎掉的包装层?** ## 先说为什么那些「显然的做法」都不行 **直接改应用的 HTML。** 没东西可改。这一步很多人都跳过,但它决定了后面整套方案: ```bash curl -s https://book.example.com/index.html | wc -c # 5879 — and the body is
``` 这个页面只是一个外壳,所有按钮、文字和弹窗都是之后由 JavaScript 画出来的。所以既没有标记可以改进,也没有东西可以重排。 **在代理层重写响应。** 这个最诱人——Traefik 或 nginx 的响应体重写中间件。它失败的理由和上面一样:body 重写只能重写**body 里存在的东西**,而 body 里只有一个空 div 和一个 script 标签。这条值得当成一条通用规则记住,在任何时候伸手去用反向代理的 body 重写功能之前先做一次: > 用 `curl` 拿到原始响应,grep 一下你想干掉的那个字符串。如果它不在你收到的字节里,那么内容是客户端渲染的,body 重写就是错的工具——你需要的是 CSS 或 JS,并且注入到应用会加载的地方。 **改压缩后的 bundle。** 技术上可行,因为编译产物我看得到。但每次镜像更新都会带来重新构建(有时还重新压缩)的 bundle,于是这就变成一份对着别人的代码库长期维护的活。而且它的失败方式最糟糕:静默、在生产环境、在某个不相干的升级之后。 **把我自己的页面塞进应用的镜像里。** 这样我的落地页、SEO 标签和社交卡片就绑死在别人的发布节奏上,上游一个坏构建能把我整扇前门一起带走。我最不想做的事,就是让我的品牌依赖他们的 Dockerfile。 所以:不放进应用里,不重写应用,也不 fork。放在它**前面**。 ## 方案:多一个容器,让它持有对外端口 这条 stack 里有两个服务。应用不对外暴露任何公开端口;一个极小的 `nginx:alpine` 网关持有它,并决定每个请求是什么: ```yaml services: reader: # the app: loopback-only port kept for debugging, never public # (127.0.0.1:7778:8080) networks: [bridge_hoelee] reader-gateway: image: nginx:alpine ports: - "7777:80" # the only public door networks: [bridge_hoelee] volumes: - /volume1/docker/reader/gateway/conf/default.conf:/etc/nginx/conf.d/default.conf:ro - /volume1/docker/reader/gateway/html:/usr/share/nginx/html:ro ``` nginx 那一侧短到可以整段读完。有意思的决定不在路由本身,而在路由周围那四行: ```nginx # resolve the app at request time, not at startup resolver 127.0.0.11 valid=10s ipv6=off; set $reader_upstream http://reader:8080; location = / { # the front page: exact match, bare root only root /usr/share/nginx/html; try_files /index.html =404; add_header Cache-Control "no-store" always; } location / { # everything else: the app, untouched proxy_pass $reader_upstream; client_max_body_size 1024m; # uploads still work through the middle box } ``` **在请求时解析上游,而不是启动时。** 如果写成字面量 `proxy_pass http://reader:8080`,nginx 只在启动那一刻解析这个名字一次,解析不到就拒绝启动——于是应用每次重启或被替换,我的前门就跟着下线。改用变量加 Docker 内嵌 DNS(`127.0.0.11`),就把解析推迟到每个请求。应用停着的时候网关照样能启动,这件事在你第一次重建应用时很重要,在你半夜排查时也很重要。 **把应用自己的端口留给调试,而且一次改完。** 应用仍然在 loopback 上发布 `127.0.0.1:7778:8080`:从宿主机通过 SSH 能连,公网看不见。做这次切换时,旧容器必须和网关在**同一次** stack 更新里交接那个端口;拆成两次部署会绑定失败。 **别让中间那台机器变成新的限制。** 网关设置了 `client_max_body_size 1024m`,因为这个应用允许上传大体积的电子书。在一个有文件上传的应用前面加代理,是引入「只在某天有人上传大文件时才出现」的 bug 的经典方式。 **不同路由用不同缓存时长。** 首页是 `no-store`(我直接在磁盘上编辑它,希望刷新即生效),社交图缓存一天,注入脚本缓存五分钟。各一行。 **精确区分 `/` 与 `/index.html`。** `location = /` 里特意写了 `try_files /index.html =404` 而不是依赖默认的 `index` 指令——因为 nginx 的 `index` 会触发一次到 `/index.html` 的内部重定向,那会直接跳进后面代理应用外壳的 `location = /index.html` 块。用 `try_files` 在当前块直接读出本地门面页,而门面页上的「进入」按钮则显式跳转到 `/index.html` 触发上游代理。 ## 页面本身 它是一个自包含的 HTML 文件:没有网络字体、没有 CDN、没有构建步骤。这不是为了极简而极简——它意味着这扇前门没有任何第三方依赖会变慢、被墙或停止服务,而且断网时它还能从缓存里渲染出来。 里面有两个决定值得照抄: **两种语言同时存在于 DOM 里。** 每一句文案都出现两次——一个 `.zh` span 和一个 `.en` span——由 `` 上的一个属性通过 CSS 决定显示哪一套。默认跟随浏览器语言,用 `?lang=en` / `?lang=zh` 可以覆盖,选择记在 `localStorage`。因为两种语言都在 HTML 里,搜索引擎会两种都收录,这是 JavaScript 切换器做不到的。 **邀请码故意不放在页面上。** 页面明确写着邀请码印在实体邀请卡上,而不是把它显示出来。一个公开的落地页如果泄露注册码,就等于把一个只发邀请的服务变成开放注册。 ## 注入进应用的三行,以及它们为什么是重点 一个放在应用前面的落地页,如果不把接缝藏掉,看起来仍然是两个产品。应用所在的 location 块里有三条 `sub_filter` 规则负责这件事——它们在应用的外壳经过时打补丁(那 5,879 字节,是客户端渲染应用里唯一真的存在于字节中的部分): ```nginx location = /index.html { proxy_pass $reader_upstream; proxy_set_header Accept-Encoding ""; # sub_filter cannot rewrite a compressed body # 1. the app declares the wrong language, which disables browser translation sub_filter '' ''; # 2. reload/bookmark/new tab -> back to the front door; the enter button sets the pass sub_filter '' ''; # 3. inject the client-side English UI translation layer before closing body sub_filter '' '