From Build to Browser

What Caddy Should Know About Your Astro Site

14.04.2026 | 26 Shawwal 1447
8 min read

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

The Gap Between Building and Serving

Astro does a lot of work at build time. It compiles pages to static HTML, hashes every asset into the _astro/ directory, and produces files ready for any static server. The CI pipeline adds another layer: pre-compressing everything into .br, .gz, and .zst sidecar files so the server never has to compress on the fly.

All of that effort can be quietly undone by the last layer in the chain: the web server configuration. Caddy serves the files, but it doesn’t know what it’s serving. It doesn’t know that _astro/ files have content hashes in their names. It doesn’t know that HTML changes on every deploy while CSS doesn’t. It doesn’t know that the site is HTTPS-only with no reason to ever be iframed.

That knowledge gap is what the config is for. And for a while, mine looked like this:

(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
}
}

It worked. Files got served, pre-compressed assets got picked up, the 404 page showed when it should. But that’s about it. No security headers, no cache strategy beyond the favicon, no www redirect. The browser was making decisions that should have been mine. And anyone who ran a security scan or checked page speed would see exactly how much was left on the table.

What Actually Matters for a Static Site

I went through Caddy’s directive documentation — all of it. Headers, caching, compression, logging, TLS options, server timeouts, Prometheus metrics. The question wasn’t “what can Caddy do?” but “what does my site actually need?”

A static site with no login, no forms, no user data, and no backend has a very different threat model than a web application. Most security guides are written for the latter. Applying them blindly to a static site means adding complexity that protects against nothing.

So I evaluated each option against a simple test: does this address a real threat, or does it just make a scanner happy?

Three Security Headers, Not Six

The internet has plenty of lists telling you to add every security header that exists. Here’s what I added and why. And what I didn’t.

Strict-Transport-Security addresses a real threat. Without it, a visitor on public WiFi could have their connection downgraded to HTTP and intercepted. The site is already HTTPS-only, so this header just tells browsers to never try HTTP. One year, including subdomains.

X-Content-Type-Options: nosniff prevents browsers from guessing content types. Without it, a browser might interpret a text file as executable code. One value, no configuration, no trade-offs.

X-Frame-Options: DENY blocks anyone from embedding the site in an iframe. Prevents clickjacking, where an attacker overlays invisible UI on top of your page to steal clicks. No reason for this site to be iframed, so DENY is the right answer.

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

That’s it. Three headers that address three real attack vectors. Here’s what I evaluated and skipped:

Referrer-Policy already defaults to strict-origin-when-cross-origin in every modern browser. Setting it explicitly changes nothing. External sites see your domain as the referrer (good for SEO), but not the full path. This is already the correct behavior without touching the config.

Permissions-Policy disables browser APIs like camera, microphone, geolocation. But a static site doesn’t use these APIs. Blocking something you’re not using doesn’t protect you from anything. If an attacker can inject JavaScript to access the camera, you have far bigger problems than a missing header. And if you embed YouTube videos with fullscreen enabled, as I do, you’d have to carefully carve out exceptions. More complexity for zero security benefit.

Cross-Origin-Opener-Policy prevents other sites from getting a reference to your browser window. This matters for sites with authentication flows where a popup could steal tokens. A static site has no auth, no tokens, no popups.

Content-Security-Policy is genuinely powerful but dangerous. It controls exactly which scripts, styles, fonts, and images can load on your page. Get it wrong and your page breaks silently. No error, no console warning, just a missing font or a script that doesn’t run. It needs a careful audit of every resource your pages load: inline scripts, YouTube iframes, external fonts. I’ll inshallah add it later, but not without testing.

The pattern: each skipped header either duplicates a browser default, blocks something that isn’t a threat, or requires work that hasn’t been done yet. None of them were rejected because security doesn’t matter. They were rejected because they don’t do what people think they do, at least not for a static site.

Cache Strategy: Let the Build Output Guide You

This is where knowing your framework’s output matters most.

Astro puts every processed asset into the _astro/ directory with a content hash in the filename: JS, CSS, fonts, SVGs, images. arc.FDYzXBdN.js. If the content changes, the hash changes, and the filename changes. The old URL is never reused.

This means the browser can cache these files forever. Not “a long time” — literally forever. The hash guarantees that if the content changes, the URL changes, so the browser will always request the new version. There’s no risk of serving stale content.

Without this, every repeat visitor re-downloads your CSS, JS, and fonts on every page load. On a mobile connection, that’s the difference between a site that feels instant and one that feels slow. The content hasn’t changed since their last visit, but the browser doesn’t know that.

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

max-age=31536000 is one year. immutable tells the browser not to even bother revalidating. Don’t send a conditional request, don’t check if it changed, just use the cached copy. This is the same policy every CDN uses for hashed assets.

HTML files are different. They have no hash in the filename. /en/blog/some-post/index.html stays the same URL across deploys. When I publish a new version, the HTML changes but the URL doesn’t. So the browser needs to check with the server before using a cached copy.

header ?Cache-Control "no-cache"

no-cache doesn’t mean “don’t cache.” It means “cache it, but ask the server first.” If the file hasn’t changed, Caddy returns a 304 Not Modified, a tiny response, practically free. If it has changed, the browser gets the new version. The ? prefix means this only applies if no other Cache-Control was already set, so it doesn’t override the _astro/* or favicon rules.

Favicons sit in between. No hash, but they rarely change:

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

Three rules, and each one follows directly from what Astro’s build produces. Hashed files get cached forever. Unhashed files get revalidated. That’s the entire strategy.

The Small Things

Without a www redirect, www.javedab.com and javedab.com are separate sites in Google’s eyes. Link equity splits between two domains. A 301 permanent redirect consolidates everything to one canonical URL and preserves the full path.

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

The CI pipeline pre-compresses all assets, and Caddy serves the sidecar files directly. But if a file somehow doesn’t get pre-compressed, a compression fallback should compress it on the fly rather than serving it uncompressed. The encode directive is a safety net. It should rarely fire, and if it does, it means something in the build pipeline needs attention.

encode zstd gzip

ACME email is easy to overlook. Caddy manages TLS certificates automatically via Let’s Encrypt. If renewal fails, the site goes down with a TLS error. Visitors see a browser warning, and some won’t come back. Without an email on the ACME account, that failure is silent. You find out when a customer tells you your site looks broken. One line in the global config turns it into a warning you receive before that happens.

{
email javed@javedab.com
}

What the Config Looks Like Now

(jav_static) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
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 }
}

Every line has a reason. Nothing is there because a blog post said to add it. The security headers address real threats. The cache rules match the build output. The compression fallback is a safety net, not the primary path.

The Point

Your web server config should reflect what your build pipeline produces. Generic defaults work, but they leave decisions to the browser that should be yours. Caddy’s defaults are better than most. HTTP/3 is on, TLS is automatic, pre-compressed sidecar files are supported natively. But caching, security headers, and redirects are things only you can configure, because only you know what your framework outputs and what your site needs.

The whole process took one session. Reading the docs, evaluating each option, implementing the ones that matter, verifying with curl. The config went from five functional lines to a setup that any security scanner or Lighthouse audit will inshallah pass cleanly.

None of this is visible to a casual visitor. The site looked the same before and after. But it’s visible to anyone who checks. A potential partner who runs your domain through securityheaders.com. A client who opens DevTools and sees immutable on your assets. The kind of person who notices whether the infrastructure behind the page is as intentional as the page itself.

That’s what owning your infrastructure looks like. Not complicated — just intentional.

Aerospace engineer

Ethical entrepreneur in public

You handle your business

I handle the digital side

Work with me
  • AI with honesty
  • Private infrastructure
  • Websites that perform

Tell me about your situation:

javed@javedab.com More about me