alepha@docs:~/docs/framework/guides/deployment$
cat 7-headers.md | pretty
4 min read
Last commit:

#Static File Headers

A host that answers a file itself never runs your app, so the headers your app sends never reach that file. Cloudflare serves dist/public from its asset store; Bay serves it from disk. Without something else, a hashed chunk there revalidates on every page load and your HTML goes out with no Strict-Transport-Security, no X-Frame-Options, no X-Content-Type-Options.

That something else is one file, dist/public/_headers, in Cloudflare's format. alepha build writes it, and every host applies it the same way.

#Where it applies

Host Applies _headers Applies _redirects
Cloudflare, through alepha platform up (wrangler) yes, to every file of the asset store yes
Cloudflare, through lore apps deploy yes, to every file of the asset store yes
Bay yes, to every file it serves from disk, fallback pages included no
The app's own server: node dist/index.js, Bun, Docker, --compile yes, to every file its static server answers, from disk or the binary no

On every host, as on Cloudflare, the file never touches a response your app itself produced: a page rendered by SSR, an API call. Those already carry the app's headers.

/_headers, /_redirects and /.assetsignore are configuration, never files: no host serves them.

#What the build writes

txt
# Generated by `alepha build`. Edit public/_headers or helmetOptions, never this file.

/*
  Strict-Transport-Security: max-age=15552000; includeSubDomains
  X-Content-Type-Options: nosniff
  X-Frame-Options: SAMEORIGIN
  Referrer-Policy: strict-origin-when-cross-origin

<your public/_headers>

/entry.*
  ! Cache-Control
  Cache-Control: public, max-age=31536000, immutable

/chunk.*
  ! Cache-Control
  Cache-Control: public, max-age=31536000, immutable

/asset.*
  ! Cache-Control
  Cache-Control: public, max-age=31536000, immutable
  • /* carries the security headers helmetOptions produces, the same values the server sends with a rendered page. It is left out when helmet is disabled.
  • Your own public/_headers goes in the middle, verbatim. It is optional: most apps need none.
  • The three hash rules come last. A name that carries its content hash can never change meaning, so it is cached for a year whatever a broader rule of yours says about images or scripts.

A site built by something else (static.source, see Static Deployment) gets the /* rule and its own _headers, and no hash rule: its file names were never Alepha's to promise anything about.

#Writing public/_headers

A rule is a path, then the headers under it:

txt
# A day of caching for images that carry no hash.
/*.png
  Cache-Control: public, max-age=86400

# Served as text, so a browser shows the script rather than downloading it.
/install.sh
  Content-Type: text/plain; charset=utf-8
  • A path is exact, or has one *, which matches any characters, / included, or none. Write it percent-encoded, the way a browser sends it: rules match the request's path as received, never the file that ends up served.
  • Name: value sets a header. ! Name removes one.
  • Every rule that matches a path applies, in file order, removals first within a rule.

The build refuses anything Cloudflare would silently skip, join or replace, naming the line: a named placeholder (/movies/:title), a host (https://...), two *, :splat in a value, the same path twice, the same header twice in one rule, a rule with no header, more than 100 rules, a line over 2,000 characters.

#No silent joins

On Cloudflare, two matching rules that set the same header do not override each other: the values are joined. /*.png setting a day and /asset.* setting a year would give a hashed image both, public, max-age=86400, public, max-age=31536000, immutable, which a cache may read as stale.

So the build refuses two rules that can match one path and both set a header, unless the later one removes it first. An override is always written out:

txt
/docs/*
  Cache-Control: public, max-age=3600

/docs/changelog
  ! Cache-Control
  Cache-Control: no-store

#Adding to /*

A /* of yours is folded into the generated one rather than written as a second /*: of two rules for one path, Cloudflare silently keeps only the last. So a site-wide header helmet does not model is just a /* rule:

txt
/*
  Permissions-Policy: camera=(), microphone=()

To change a header the build already sets there, remove it in the same rule. The build then drops its own line, and your value is the only one:

txt
/*
  ! X-Frame-Options
  X-Frame-Options: DENY

Setting it without ! X-Frame-Options is refused: it would be a silent join.

#Reserved names

A top-level file of your public directory named entry.*, chunk.* or asset.* is refused. It would be cached for a year under a name you can overwrite, and a browser that has it would never see the new one.

#Caching a file no rule caches

Cloudflare answers every such file with cache-control: public, max-age=0, must-revalidate: the browser keeps it and asks whether it changed before using it again. With a _headers present, Bay and the app's own server answer exactly the same, so one artifact caches the same way on every host.

That is a change for the app's own server, which used to give every .js, .css, image and font an hour whatever its name. A logo or a file under /fonts/ now revalidates on every load unless a rule of yours caches it, and the content-hashed files, which are nearly all of a page's weight, are cached for a year instead of an hour.