# 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 deploy` (Cloudflare API)                     | yes, to every file of the asset store                                 | yes                  |
| Cloudflare, through `lore apps deploy`                                   | yes, to every file of the asset store                                 | yes                  |
| [Bay](/docs/guides-deployment-bay)                                       | yes, to every file it serves from disk, fallback pages included       | no                   |
| The app's own server: `node dist`, Bun, `alepha image`, `alepha 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](/docs/guides-deployment-static)) 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.
