Headers, redirects and 404 pages
Vura serves the static files of static sites, Vite apps and the prerendered pages of Vura apps from its edge. You can set response headers, add redirects and use your own 404 page. Headers and redirects go in vura.json, in your project's root directory.
Vura reads headers and redirects after your build command finishes, so your build can write them. One example is a Content-Security-Policy with script hashes. Every other vura.json key is read before the build.
Headers
{
"headers": [
{
"source": "/(.*)",
"headers": {
"X-Frame-Options": "DENY",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains"
}
},
{
"source": "/assets/(.*)",
"headers": { "Cache-Control": "public, max-age=31536000, immutable" }
}
]
}
- Every rule whose
sourcematches the request path applies, in order. If two rules set the same header, the later rule wins. - Rules apply to everything served from static storage: pages, assets, the single-page app shell, 404 pages and redirects.
- Rules don't apply to responses from your server code: Function and Dedicated routes, and services. Set those headers in your app. Vercel applies
headersto function responses too. Vura doesn't, so a value your server computes per request, such as a CSP nonce, is never overwritten. - A rule's
Cache-Controlonly applies to 200, 206 and 304 responses. A 404 or a redirect is never cached for a year by mistake. - Header values must be printable ASCII. A curly quote (’) pasted from a document fails the build.
Headers Vura sets
| Header | Default | Can a rule change it? |
|---|---|---|
X-Content-Type-Options |
nosniff |
Only to nosniff |
X-Frame-Options |
SAMEORIGIN |
Yes, for example to DENY |
Strict-Transport-Security |
Not sent | Yes. HSTS stays off until you add it |
Cache-Control |
See "Caching" below | Yes |
Content-Type |
From the file extension | Yes |
A rule can't set these headers, and the build fails if one tries: Cache-Tag, Server, any x-vura-* header, Set-Cookie, Location (use redirects), ETag, Content-Length, Content-Encoding, Content-Range, Transfer-Encoding, Connection, Keep-Alive, TE, Trailer, Upgrade, Proxy-Authenticate and Proxy-Authorization.
Redirects
{
"redirects": [
{ "source": "/old-pricing", "destination": "/pricing", "permanent": true },
{ "source": "/blog/:slug", "destination": "/posts/:slug" },
{ "source": "/docs/(.*)", "destination": "https://docs.example.com/$1", "permanent": true }
]
}
- Redirects run before your pages and API routes.
- On a project that runs services, none of this page applies: every request goes straight to the service, before
vura.jsonheaders, redirects or the 404 page ever run. The service handles its own headers, redirects and missing routes. - The first matching rule wins.
"permanent": truesends308. Otherwise, which is the default, Vura sends307. Both keep the request method.destinationis a path starting with/or anhttps://URL.- The query string is kept, unless
destinationhas its own?. - Vura skips a redirect that would send a URL to itself. The build rejects a rule whose
destinationequals itssource. /__tasks/*is reserved for Vura and is never redirected.
Using parts of the path
In destination |
Is replaced by |
|---|---|
:name |
The :name, :name* or :name+ part of source |
$1 to $9 |
The first to ninth captured part, in order, counting (.*) too |
$0 isn't a token: it stays in destination as ordinary text.
With :name*, an empty match also drops the / in front of it. For example, with /docs/:path* → /guide/:path*, /docs goes to /guide and /docs/a/b goes to /guide/a/b. Parameters work in the path and query of destination, never in the host.
Path patterns
source uses the same patterns in both lists.
| Pattern | Matches | Doesn't match |
|---|---|---|
/about |
/about |
/about/, /About, /about/team |
/blog/:slug |
/blog/hello |
/blog, /blog/a/b |
/docs/:path* |
/docs, /docs/, /docs/a/b |
/docsx |
/docs/:path+ |
/docs/a, /docs/a/b |
/docs, /docs/ |
/assets/(.*) |
/assets/, /assets/app.js, /assets/a/b |
/assets |
/(.*) |
Every path | |
/:path+/ |
/docs/, /docs/install/ |
/, /docs |
- Matching is case-sensitive, like file names.
- An exact path must match exactly, including a trailing slash.
- A pattern has at most one
(.*),:name*or:name+, and it must come last. - Patterns match the path as the browser sent it.
%2Fis not a/. Non-ASCII text such as/caféworks as written: Vura percent-encodes it for you, and a%xxescape you type by hand is uppercased to match, so/caf%c3%a9still matches a request for/caf%C3%A9. - Not supported: regular expressions (
:id(\\d+)), optional parameters (:name?), a bare*(use(.*)) andhas/missingconditions. The build names the part it can't use.
Trailing slashes
/pricing and /pricing/ both serve pricing/index.html. To keep one address per page, like Vercel's "trailingSlash": false, add this redirect:
{ "redirects": [{ "source": "/:path+/", "destination": "/:path+", "permanent": true }] }
It sends /pricing/ to /pricing and leaves / alone.
404 page
If your output has a 404.html at its root, Vura serves it with status 404 whenever a page or file doesn't exist. Without one, Vura shows its own 404 page.
- This is for static and prerendered sites.
404.htmlthere is not a page of its own:/404also answers with status 404. - A single-page app still serves its
index.htmlshell for/404and any other path without a file extension, so client-side routing keeps working. Any other HTML file in the build, such as a prerenderedpricing/index.htmlor apublic/embed.html, is served as its own page at its path, so it wins over a client-side route with the same path.404.htmlon an SPA only answers for a genuinely missing file, such as an old/assets/app-1234abcd.js. /api/*and/__tasks/*keep Vura's JSON 404, so API clients still get JSON.
HEAD
A HEAD request to a static file, the SPA shell or the 404 page gets the same status, headers and caching a GET would, with no body.
A server-rendered page, Function or Dedicated, is published for GET only. HEAD on it always returns 404 at the edge, no matter what your own code does: Vura's edge only upgrades HEAD into a GET for static paths. Monitor a server-rendered page with GET, not HEAD.
Caching
| File | Cache-Control |
|---|---|
Name with a content hash, such as main-BNwSecox.js, index.a1b2c3d4.css or chunk-5XKQZ2LM.js |
public, max-age=31536000, immutable |
| Everything else, including HTML | public, max-age=0, must-revalidate |
Vura only treats a name as hashed when the hash looks random, so a file such as settings.js, IMG_1234.jpg or photo-20240101.jpg is never cached for a year. The check is cautious: about one real hash in eight looks too much like a word and keeps revalidating, which costs a quick 304 and nothing else. Rarely, a hand-named file such as hero-Web3Logo.png looks random enough to be cached as immutable. A headers rule overrides the check either way: to cache a whole folder as immutable, add a rule like the /assets/(.*) example above.
Headers written by your build
A strict Content-Security-Policy lists hashes of inline scripts, and those exist only after the build. Write them into vura.json at the end of your build, for example with "build": "vite build && node scripts/write-csp.mjs":
// scripts/write-csp.mjs
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
const html = readFileSync('dist/index.html', 'utf8');
const hashes = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)]
.map(([, code]) => `'sha256-${createHash('sha256').update(code).digest('base64')}'`);
const config = JSON.parse(readFileSync('vura.json', 'utf8'));
config.headers = (config.headers ?? []).filter((rule) => !('Content-Security-Policy' in rule.headers));
config.headers.push({
source: '/(.*)',
headers: {
'Content-Security-Policy':
`default-src 'self'; script-src 'self' ${hashes.join(' ')}; connect-src 'self' ${process.env.VITE_API_URL ?? ''}`.trim(),
},
});
writeFileSync('vura.json', `${JSON.stringify(config, null, 2)}\n`);
If the file your build leaves behind breaks one of the rules on this page, the build fails with a message naming the rule, and the deploy is refused before it goes live.
Limits
- 50 header rules and 50 redirects.
- Between 1 and 50 headers per rule (an empty rule fails the build), with each value up to 4,096 characters.
headersandredirects, together with the rest of your build config, must fit in 64 KB. There's no separate cap per list.sourceup to 256 characters and 32 segments.destinationup to 2,048 characters.
Coming from vercel.json
| vercel.json | vura.json |
|---|---|
"headers": [{ "source": "/(.*)", "headers": [{ "key": "X-Frame-Options", "value": "DENY" }] }] |
"headers": [{ "source": "/(.*)", "headers": { "X-Frame-Options": "DENY" } }] |
"redirects" with permanent |
The same shape |
permanent omitted |
Vercel sends 308, Vura sends 307. Set permanent explicitly. |
"trailingSlash": false |
The redirect under "Trailing slashes" |
"cleanUrls": true |
Not needed: about.html and about/index.html are both served at /about |
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] |
Not needed: Vite apps get this automatically |
Redirects between hosts, such as www to the bare domain |
Set on the domain in the dashboard |
has, missing, statusCode, regex patterns |
Not supported |
Where this applies
Headers and redirects come from builds that Vura runs from Git. vura deploy from the CLI uploads a finished dist/ and doesn't read them. The 404 page and caching work for both. Each deployment keeps the rules it was built with, so a rollback also restores its headers and redirects.