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 .gz at 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:

  1. 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.
  2. Accept script-src 'unsafe-inline' and put the weight of the CSP on frame-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:

  • site in astro.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/ is immutable, HTML is no-cache
  • gzip_static on for text, off for images
  • Unknown paths return 404, not the homepage
  • /404.html actually exists and has navigation in it
  • robots.txt and sitemap.xml carry the new domain
  • You rebuilt after changing the domain

← Back to all posts

Comments