#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
# 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 headershelmetOptionsproduces, the same values the server sends with a rendered page. It is left out when helmet isdisabled.- Your own
public/_headersgoes 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:
# 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: valuesets a header.! Nameremoves 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:
/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:
/*
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:
/*
! 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.