Skip to content

Response headers (_headers)

A _headers file in your site sets the HTTP response headers the network sends with your pages and files: security headers, a Content-Security-Policy, CORS, and how long files are cached. It uses the same format as Netlify and Cloudflare Pages, so a file you already have usually works unchanged.

It applies to Level 0 and Level 2 sites, not Level 1. See Scope.

Put _headers in the root of your build output, next to your home page’s index.html. Most generators copy a static folder into the output unchanged, so put the file there:

Generator Put _headers in
Astro, Vite, Qwik City public/
VitePress docs/public/ (your source folder’s public/)
Hugo, Docusaurus, Gatsby static/
Eleventy Any folder you pass through to the output
Jekyll The site root, with include: [_headers] in _config.yml

The file travels with your site. It deploys and rolls back with the content it belongs to.

Write a path at the start of a line, then its headers indented beneath it. Lines that start with # are comments.

/*
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
/assets/*
Cache-Control: public, max-age=31536000, immutable
  • * matches any characters within one part of the path, and a trailing /* matches everything beneath a folder. /app-*.js matches app-39990451.js at the root only.
  • :name at the start of a path part matches that whole part.
  • One part of a path can’t use both.

Every rule that matches is applied. This is different from _redirects, where the first match wins.

  • If two rules set the same header, the later rule wins. Put general rules first and specific ones after them.
  • If one rule sets the same header twice, the values are joined with a comma.
  • Rules match the address the visitor asked for, not the file that ends up being served. A request for /docs/ matches /docs/, not /docs/index.html. A request for a missing page under /app/ still matches /app/*, and doesn’t match a rule written for /404.html.

The network sets these itself, and a rule for one is dropped with a warning when you deploy:

Accept-Ranges, Age, Allow, Alt-Svc, Connection, Content-Encoding, Content-Length, Content-Range, Date, ETag, Location, Server, Trailer, Transfer-Encoding, Upgrade, and any header starting X-EE-.

To send visitors somewhere else, use _redirects instead of Location.

A Cache-Control you set replaces the default for the paths it matches. The defaults are:

What Default
HTML pages Checked with the network on every request
Files in _astro/, _assets/, _next/static/, _nuxt/ and _app/immutable/ Cached for a year, as immutable
Everything else Cached for an hour

Two cases keep the network’s own setting whatever you write:

  • Not-found responses. A 404 never gets your Cache-Control, so a mistyped address isn’t cached for a year.
  • Encrypted sites (Level 1 and Level 2). Their content is always served no-cache, so a shared cache can never hold decrypted content. Every other header you set still applies at Level 2.

Most generators put content-hashed files (index-CAoPt-vL.js) in a folder of their own. A file there never changes without its name changing, so you can cache the whole folder for a year.

Only do this if every file in that folder is hashed, in every build. A browser that has cached a file for a year won’t fetch it again. Check that nothing in your static folder copies an unhashed file into the hashed folder (for example public/assets/logo.png in a Vite project).

Vite, VitePress and Docusaurus:

/assets/*
Cache-Control: public, max-age=31536000, immutable

Qwik City (static adapter):

/build/*
Cache-Control: public, max-age=31536000, immutable

Gatsby. page-data/ must be checked on every request, or a new deploy can show old page data next to new JavaScript:

/page-data/*
Cache-Control: public, max-age=0, must-revalidate
/static/*
Cache-Control: public, max-age=31536000, immutable
/app-*.js
Cache-Control: public, max-age=31536000, immutable
/framework-*.js
Cache-Control: public, max-age=31536000, immutable
/webpack-runtime-*.js
Cache-Control: public, max-age=31536000, immutable
/component---*.js
Cache-Control: public, max-age=31536000, immutable
/styles.*.css
Cache-Control: public, max-age=31536000, immutable

Gatsby writes these files to the root of the site, beside anything from your static/ folder. Don’t give your own files names that match the patterns above, like app-config.js.

Hugo mixes fingerprinted files with the files static/ copies, so no folder is safe by default. Keep fingerprinted resources in a folder of their own (for example assets/fp/, piped through fingerprint, which publishes to /fp/), then:

/fp/*
Cache-Control: public, max-age=31536000, immutable

Your build may differ from the ones these were checked against: plugins and newer versions can change where files land. Look at your own output folder before you publish a year-long rule.

You can set a Content-Security-Policy, and your site keeps working with it.

The network adds a small inline script and style to every HTML page, which load your site’s files. A strict policy such as script-src 'self' would normally block them. So when you set a policy, the network adds a hash for each of its own inline blocks to your script-src or style-src (or to default-src if you set neither). Your own sources are left as you wrote them.

  • It never adds 'unsafe-inline'.
  • It leaves a policy alone if the policy has no script-src or default-src, or if it already allows 'unsafe-inline'.
  • Content-Security-Policy-Report-Only is handled the same way, so you can test a policy before enforcing it. You can set both at once.

Under an enforced policy that restricts scripts, some of the hints the network gives browsers about nearby nodes are left out, because they change with every response and can’t be hashed. Your site is otherwise unaffected.

A public site can be set up so that visitors’ browsers fetch its files from nearby operator nodes. That’s arranged through Customer Support for now. A file fetched that way doesn’t carry your _headers rules. Pages always come from a regional gateway, with your rules applied.

To keep that safe, a rule that would change how a browser treats a file keeps your whole site on regional gateways only. These headers are fine anywhere and don’t affect it: Content-Security-Policy (enforced or report-only), X-Content-Type-Options, Referrer-Policy, X-Frame-Options, Permissions-Policy, CORS headers, the cross-origin isolation headers and Cache-Control.

A Content-Type override, Set-Cookie, or any header not in that list, set on a file that isn’t a page, turns delivery from operator nodes off for the site. Headers set only on pages never do.

  • Level 0 and Level 2 sites. See encryption levels.
  • Not Level 1. The network can’t read a Level 1 site’s contents, including this file, so no custom header is sent.
  • At Level 2, headers are applied only to responses for requests that carry a valid access token.
Limit Value If you go over
Rules per file 100 The rest are dropped, with a warning when you deploy
Characters per line 2,000 That line is dropped
Total size of your headers on one response 8 KiB The overflow is dropped

A line that can’t be read is skipped, with a warning naming the line when you deploy, so one bad line doesn’t remove the rest of your headers.

The file isn’t downloadable. A request for /_headers gets a 404.

After deploying, request a page and look at what comes back:

Terminal window
curl -sI https://www.example.com/

Next: Custom 404 pages >