从 Build 到浏览器

Caddy 应该知道你 Astro 站点的哪些事

14.04.2026 | 26 Shawwal 1447
16 min read

بِسْمِ ٱللَّهِ ٱلرَّحْمَـٰنِ ٱلرَّحِيمِ

Build 与交付之间的间隙

你有一个网站。它能加载、能工作、显示的是你想让它显示的内容。但你是否知道,回访的用户是立刻看到页面,还是每次点击都重新下载一切?连接是否会被降级到 HTTP?你的主页在 Google 上是以一个域名出现,还是两个?对大多数站点来说,答案是:不知道、会、会。这最后一层是 web 服务器的配置,而大多数企业从来不去动它。

Astro 在 build 阶段做了很多工作。它把页面编译成静态 HTML,给 _astro/ 目录里的每个资源加上 content-hash 文件名,生成可以被任何静态服务器直接提供的文件。CI 流水线再加一层:把所有东西预压缩成 .br.gz.zst 的 sidecar 文件,让服务器永远不需要 on the fly 压缩。

所有这些努力都可能被链条里最后一层悄悄抹去:web 服务器的配置。Caddy 提供文件,但它不知道自己在提供什么。它不知道 _astro/ 里的文件名带着 content-hash。它不知道 HTML 每次部署都会变,而 CSS 不会。它不知道这个站点只走 HTTPS,也没有任何理由要被嵌在 iframe 里。

这个知识缺口就是配置存在的原因。我的配置有一段时间是这个样子:

(jav_static) {
header /favicon.svg Cache-Control "max-age=3600, must-revalidate"
file_server {
precompressed br gzip zstd
}
handle_errors {
rewrite * /404.html
file_server
}
}

它能跑。文件被提供出去,预压缩的资源被识别,404 页面该出现时会出现。但也就这样了。没有安全响应头,除了 favicon 之外没有缓存策略,没有 www 跳转。浏览器在替我做本该由我做的决定。任何人跑一次安全扫描或查一下页面速度,都会看到这里浪费了多少可以做的东西。

静态站点真正重要的是什么

我把自己在评估的 Caddy 指令过了一遍:headers、caching、compression、logging、TLS 选项、服务器超时、Prometheus 指标。logging、TLS、超时和指标,对这种规模的静态站点来说不需要专门决策。Caddy 的默认值就够用。真正值得决定的是 headers、caching 和 compression。问题不是「Caddy 能做什么」,而是「我的站点到底需要什么」。

外部链接 caddyserver.com/docs/caddyfile/directives

一个没有登录、没有表单、没有用户数据、没有后端的静态站点,面临的威胁模型跟 web 应用完全不同。大多数安全指南是为后者写的。不假思索地套到静态站点上,就是在加一堆什么都保护不到的复杂度。

所以我用一个简单的测试来评估每个选项:这个真的能应对一个实际的威胁,还是只是让扫描器满意?

三个安全响应头,而不是六个

网上充满了告诉你「把每个存在的安全响应头都加上」的清单。加进来的是什么,没加的是什么。

Strict-Transport-Security 针对一个实际威胁。如果没有它,在公共 WiFi 上的访客可能会被把连接降级到 HTTP 并被拦截。这个站点本来就只走 HTTPS,所以这个响应头只是告诉浏览器永远不要再试 HTTP。一年,包含子域名,加上 preload 来加入浏览器内置的 HSTS preload 列表。代价是真实的:当前和未来的每一个子域名都必须走 HTTPS,否则就访问不到。从 preload 列表里移除不在你手上:它发生在浏览器的发布周期里,而不是在你的配置里。这里选择接受这个代价,因为这个站点本来就是按只走 HTTPS 来设计的,而 Caddy 会通过 Let’s Encrypt 自动为每个子域名申请证书。

外部链接 hstspreload.org

X-Content-Type-Options: nosniff 阻止浏览器猜测内容类型。没有它,浏览器可能把一个文本文件当作可执行代码来处理。一个值,零配置,没有权衡。

X-Frame-Options: DENY 禁止任何人把这个站点嵌入 iframe。这能防止点击劫持:攻击者把看不见的 UI 叠在你的页面上方来窃取点击。这个站点没有任何理由出现在 iframe 里,所以 DENY 就是正确答案。

header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
}

就这些。三个响应头,应对三个实际的攻击面。被否决的选项和为什么:

Referrer-Policy 在主流浏览器中默认就是 strict-origin-when-cross-origin。显式设置它什么都不会改变。外部站点把你的域名当作 referrer(对 SEO 有好处),但看不到完整路径。正确的行为已经在那里了,用不着动配置。

Permissions-Policy 禁用摄像头、麦克风、地理位置之类的浏览器 API。但静态站点本来就不用这些 API。禁用一个你根本没在用的东西,并不能保护你免于任何威胁。如果攻击者能够注入 JavaScript 访问摄像头,你面对的问题远远大过一个缺失的响应头。而如果你像我一样嵌入了开启全屏的 YouTube 视频,就得小心地切出例外。更多复杂度,换来零安全收益。

Cross-Origin-Opener-Policy 阻止其他站点获取对你浏览器窗口的引用。这在有认证流程的站点上才有意义,因为那里一个弹出窗口可能会偷走 token。静态站点没有认证、没有 token、没有弹出窗口。

Content-Security-Policy 是一个运行时的白名单,管理脚本、样式、字体和图片。它能抑制 cross-site scripting,阻断数据外泄,并在被篡改的第三方代码执行前把它拦下,无论这段代码是怎么进入页面的。对于渲染不可信数据(表单、用户内容、外部 API)的站点,它缓解的是一个主动攻击面;对于没有这些输入路径的站点,它防的是概率更低但后果更重的事件:一个被篡改的分析脚本、一个被动过手脚的 build、一个被投毒的 CDN、一次未来悄悄引入输入路径的改动。代价是真实的:每个资源都得盘点,任何疏忽都会让页面悄无声息地坏掉。Astro 6 加入了一个集成,覆盖了 Astro 自己产出的内容;iframe、外部字体和第三方运行时服务依然需要手动处理。等审计排进日程时,CSP 会因沙安拉加入。如果威胁模型先变,就会更早。

外部链接 docs.astro.build/en/reference/configuration-reference

这里的模式是:每个被跳过的响应头,要么重复了浏览器的默认行为,要么禁用的是本来就不存在的威胁,要么需要的是目前还没做完的工作。没有一个是因为「安全无所谓」而被否决的。它们被否决,是因为它们做的不是大家以为的那回事。至少在静态站点上不是。

缓存策略:让 build 输出引导你

这是最能体现「了解自己框架输出」价值的地方。

Astro 把每一个处理过的资源都放进 _astro/ 目录,文件名里带一个 content-hash:JS、CSS、字体、SVG、图片。arc.FDYzXBdN.js。内容变了,hash 就变了,文件名也跟着变。旧的 URL 永远不会被重用。

这意味着浏览器可以永远缓存这些文件。不是「很久」,是字面上的永远。hash 保证了内容一变,URL 也就变了,所以浏览器永远会请求新版本。没有提供过期内容的风险。

没有这条规则,每个回访的用户都会在每次加载时重新下载你的 CSS、JS 和字体。在移动网络上,这就是「瞬间加载」和「慢吞吞」之间的差别。内容自上次访问以来没有变化,但浏览器并不知道这一点。

header /_astro/* Cache-Control "public, max-age=31536000, immutable"

max-age=31536000 是一年。immutable 告诉浏览器连重新验证都不要做。别发条件请求,别去确认是不是变了,直接用缓存里的那一份。这是带 hash 的资源普遍采用的策略。

HTML 文件就不同了。它们的文件名里没有 hash。/en/blog/some-post/index.html 这样的 URL 跨部署保持不变。当我发布新版本时,HTML 变了,但 URL 没变。所以浏览器在使用缓存副本之前,必须先问一下服务器。

header ?Cache-Control "no-cache"

no-cache 的意思不是「不要缓存」。它的意思是「缓存,但先问服务器」。如果文件没变,Caddy 会返回一个 304 Not Modified,一个非常小的响应,几乎等于零成本。如果变了,浏览器就拿到新版本。? 前缀的意思是:只有在还没设置过 Cache-Control 时这条规则才生效,所以它不会覆盖 _astro/* 或 favicon 的规则。

Favicon 处在中间。没有 hash,但也很少变:

header /favicon.svg Cache-Control "max-age=3600, must-revalidate"
header /favicon.ico Cache-Control "max-age=3600, must-revalidate"

三条规则,每一条都直接源自 Astro 的 build 产出。带 hash 的文件永远缓存。不带 hash 的文件会重新验证。这就是完整的策略。

那些小事

没有 www 跳转www.javedab.comjavedab.com 在 Google 眼里是两个不同的站点。链接权重会在两个域名间被分割。一个 301 永久跳转把所有东西整合到一个规范 URL 上,并且保留完整路径。

www.javedab.com {
redir https://javedab.com{uri} permanent
}

CI 流水线预压缩所有资源,Caddy 直接提供 sidecar 文件。但如果有某个文件因为什么原因没有被预压缩,压缩回退应该 on the fly 压缩它,而不是直接把未压缩的内容送出去。encode 指令是一个安全网。它应该很少触发,一旦触发,就说明 build 流水线里有东西需要关注。

encode zstd gzip

ACME 邮箱很容易被忽略。Caddy 通过 Let’s Encrypt 自动管理 TLS 证书。如果续期失败,站点会带着一个 TLS 错误下线。访客会看到一个浏览器警告,其中一部分不会再回来。没有在 ACME 账号里留邮箱,这种失败就是悄无声息的。你会等到客户告诉你站点看起来坏了,才发现问题。全局配置里一行就能把它变成一个在事情发生前就到达你手里的警告。

{
email javed@javedab.com
}

配置现在的样子

(jav_static) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
}
header /_astro/* Cache-Control "public, max-age=31536000, immutable"
header /favicon.svg Cache-Control "max-age=3600, must-revalidate"
header /favicon.ico Cache-Control "max-age=3600, must-revalidate"
header ?Cache-Control "no-cache"
encode zstd gzip
file_server { precompressed br gzip zstd }
handle_errors { rewrite * /404.html; file_server }
}

每一行都有原因。没有哪一行是因为一篇博客说「加上这个」而出现的。安全响应头针对的是真实威胁。缓存规则匹配 build 的输出。压缩回退是安全网,不是主通道。

撰写本文时,一次 curl 调用确认了这些策略是活动的:

Terminal window
$ curl -sI https://javedab.com/ | grep -iE '^(cache-control|strict-transport|x-frame)'
cache-control: no-cache
strict-transport-security: max-age=31536000; includeSubDomains; preload
x-frame-options: DENY

要点

你的 web 服务器配置应当反映你的 build 流水线产出的东西。通用默认值可以用,但它们会把本应由你做的决定留给浏览器。Caddy 的默认值已经覆盖了难的那部分:HTTP/3 是开启的,TLS 是自动的,预压缩的 sidecar 文件原生支持。但是缓存、安全响应头和跳转,这些只有你能配置,因为只有你知道你的框架输出什么、你的站点需要什么。

读文档、评估每个选项、实现重要的那些、用 curl 验证。配置从几行功能性的行变成了反映这个具体站点的东西,而不是一个通用模板。

这一切对随手看一眼的访客是看不见的。站点在前后看起来一样。但对那些认真看的人来说就不是这样。一个把你的域名放进 securityheaders.com 的潜在合作伙伴。一个打开 DevTools 看到资源上的 immutable 的客户。会注意页面背后的基础设施是否跟页面本身一样有思量的人。

这就是「把基础设施握在自己手里」的样子。不复杂。只是用心。

航空航天工程师

公开透明的道德创业者

你专注你的事业

我负责数字化的部分

与我合作
  • 诚实的 AI
  • 私有基础设施
  • 高性能网站

告诉我您的情况:

javed@javedab.com 了解更多