Shipping a static site is not "upload the folder": caching, compression, honest 404s
Almost every static-hosting bug lives in response headers. Which files may be cached forever, which may not be cached at all, and why a static site must never use an SPA fallback.
A static site has no backend, which breeds one specific illusion: build it, upload dist/, done. In practice everything that goes wrong goes wrong in response headers — cache policy, compression, status codes. None of it is visible under npm run dev; it only surfaces once Nginx is in front.
The output is really three kinds of files
Sort dist/ first, because their cache policies must differ:
| Kind | Example | Does the name change? |
|---|---|---|
| Content-addressed | _astro/index.B3eZ-5oA.css |
Yes — a content hash is in the name |
| HTML | zh/blog/design-tokens/index.html |
No — URL and filename are fixed |
| Fixed-name assets | favicon.ico, og-image.png, logo-mark-64.png |
No |
Everything below follows from that table.
Caching: two tiers, split by whether the name changes
Only assets with a content hash in the filename have earned
immutable.
The hashed things under _astro/ can be cached forever:
location /_astro/ {
# Content hash in the name: change the content, change the name
add_header Cache-Control "public, max-age=31536000, immutable";
}
HTML is the exact opposite. /zh/blog/design-tokens/ is a URL that will never change. Give it a long cache and returning readers keep seeing the old version after you edit — and you have no way to make them refresh, because the URL did not change, the filename did not change, and the etag may not have changed either.
location / {
try_files $uri $uri/index.html $uri.html =404;
# Revalidate every time: HTML is the one thing whose name is fixed
# and whose content is not
add_header Cache-Control "no-cache";
}
no-cache does not mean “do not cache”. It means “ask before using”. With an etag, an unchanged file costs a 304 and almost no bytes; a changed one is live immediately. This is the single easiest thing to get wrong and the most painful consequence of getting it wrong.
Fixed-name images and icons are awkward: no hash means immutable guarantees “you replaced the logo and users still see the old one”. Either use a short TTL or version the reference. I use a short TTL, because I change posts far more often than the logo.
Compression: compress text, never images
gzip on;
gzip_static on; # serve pre-built .gz, spend no CPU per request
gzip_types text/css application/javascript application/json
image/svg+xml application/xml;
gzip_vary on;
Two points worth spelling out:
- Do not compress images. JPEG, PNG, WebP, AVIF and WOFF2 are already compressed containers. Gzipping them changes almost nothing and burns CPU.
- Prefer
gzip_static. Pre-generate the.gzat build time and just hand the file over. This site is fully static; there is no reason for the CPU to redo that work on the request path.
Brotli is 15–20% smaller than gzip and costs you a module. For a text-heavy site it is worth it; with gzip_static already in place, treat it as an optimisation rather than a requirement.
404s: the one SPA habit a static site must not copy
Most single-page-app Nginx templates contain this:
# SPA fallback: send everything to index.html
try_files $uri /index.html;
Copying that into a static site is a disaster. It makes /zh/blog/typo/ return 200 with the homepage content:
- search engines index an unbounded set of URLs that do not exist (soft 404s);
- readers believe they clicked correctly and just find the page “a bit odd”;
- your 404 page is never seen.
A static site’s real routes are all fixed at build time, so the correct move is to return 404 honestly:
error_page 404 /404.html;
location / {
try_files $uri $uri/index.html $uri.html =404;
}
A site that dares to return 404 knows which pages it has. That is a genuine advantage of static generation over “fall back to the homepage for everything”.
CSP and the script that must be inline
Security on a static site is usually easy: no server, no database, no session, so there is rarely any need for unsafe-inline.
There is one exception. The first-paint script has to be inline. What it does — decide the theme, and whether the language entry page should show itself, before the first paint — cannot be delegated to a bundled type="module", because modules are deferred and by the time one runs the page has been painted. Inline scripts and strict CSP are natural enemies.
Two ways out:
- Hash that script into
script-src. The problem is the hash changes whenever the script changes, so the build and the Nginx config must be kept in sync. - Accept
script-src 'unsafe-inline'and put the weight of the CSP onframe-ancestors,object-src,base-uri— the parts inline scripts cannot weaken.
I took the second. There is no user data and no session here, so the CSP is defending against third-party injection rather than my own scripts.
Two places the domain hides
A static site has no runtime config store, which means the domain is compiled into the output. Here it lives in two places:
siteinastro.config.mjs(absolute URLs for sitemap, canonical, RSS)- the fallback origin in
src/lib/site.ts
Change the domain and you must change both — and rebuild. Editing server_name in Nginx is not enough; the canonical in the output still points at the old domain.
Static hosting moves configuration from runtime to build time. The price is that changing config means republishing.
A checklist worth running before going live
-
_astro/isimmutable, HTML isno-cache -
gzip_staticon for text, off for images - Unknown paths return 404, not the homepage
-
/404.htmlactually exists and has navigation in it -
robots.txtandsitemap.xmlcarry the new domain - You rebuilt after changing the domain

Comments
…