files <root> {
index <name>...
not_found </page>
status <code>
}
files answers from a directory on disk. The block is optional.
site example.com {
files /var/www/example
}
| Option | Default | Meaning |
|---|---|---|
index |
index.html |
File names tried, in order, for a request that ends in / |
not_found |
none | A page under the root sent with status 404 for anything that isn't there |
status |
200 |
The status for files that are found |
How a path maps to a file
The request path, after any strip and rewrite, is percent-decoded and appended to the root. /docs/a%20b.html under files /var/www/site is /var/www/site/docs/a b.html.
These are treated as not found:
- any segment that is
.or.., - any segment that starts with a dot, such as
/.git/configor/.env, except/.well-known/, - a segment containing a backslash,
- a path containing a NUL byte, or one that is not valid UTF-8 once decoded,
- a file whose real path, after following symlinks, is outside the root.
Symlinks inside the root work as long as they point somewhere inside the root.
The root itself is resolved, symlinks included, on the first request that needs it. If it doesn't exist yet, requests answer 404 until it does. Once resolved, it stays resolved until the next reload. If you deploy by switching a symlink, for example /var/www/current to a new release folder, reload xTQ Web after the switch.
The xtq-web user must be able to read the files. Under the packaged systemd unit, files under /home and /root are not visible at all, see Install.
Directories and index files
A request for a directory without a trailing slash, like /docs, gets 308 Permanent Redirect to /docs/, query string kept, so relative links in the page resolve.
A request ending in / gets the first index file that exists in that directory:
files /var/www/example {
index index.html index.htm
}
If none exists, the request is treated as not found. There are no directory listings.
The redirect goes to the path the visitor asked for, plus /, even under strip or rewrite. With this route:
route /docs/* {
strip
files /var/www/docs
}
a request for /docs/guide looks up /var/www/docs/guide and redirects to /docs/guide/.
not_found and SPA fallback
files /var/www/app {
not_found /index.html
}
With not_found, every path that isn't there, or is refused, gets that page with status 404. The page path is relative to the root and must not contain ... If the page itself is missing, the answer is a plain 404 Not Found.
For a single-page app, not_found /index.html makes deep links like /settings/profile load the app, whose router then shows the right view. The status of those deep links is 404. Browsers render the page normally, but crawlers and link checkers see a 404. Real files such as /assets/app.js are served with 200 as usual.
For a custom error page on a normal site:
files /var/www/example {
not_found /404.html
}
status
files /var/www/maintenance {
status 503
}
Found files are sent with this status instead of 200. Combined with rewrite, it serves one fixed page for every path:
route * {
not ip 203.0.113.7
rewrite /blocked.html
files /var/www/blocked {
status 403
}
}
With a status other than 200, byte ranges and 304 Not Modified are not offered.
Response headers
| Header | Value |
|---|---|
Content-Type |
From the file extension, see below |
Content-Length |
The size |
Last-Modified |
The file's modification time |
Accept-Ranges |
bytes, when the status is 200 |
X-Xtq-Cache |
HIT, MISS or BYPASS for files up to 8 MB, see Caching |
No ETag and no Cache-Control are sent. Add a caching policy with header:
route /assets/* {
header Cache-Control "public, max-age=31536000, immutable"
files /var/www/app
}
Content types
| Extensions | Content-Type |
|---|---|
html, htm |
text/html; charset=utf-8 |
css |
text/css; charset=utf-8 |
js, mjs |
text/javascript; charset=utf-8 |
json, map |
application/json |
webmanifest |
application/manifest+json |
xml |
application/xml |
txt, sh |
text/plain; charset=utf-8 |
csv |
text/csv; charset=utf-8 |
md |
text/markdown; charset=utf-8 |
svg |
image/svg+xml |
png, jpg, jpeg, gif, webp, avif |
image/png, image/jpeg, image/gif, image/webp, image/avif |
ico |
image/x-icon |
woff, woff2, ttf, otf |
font/woff, font/woff2, font/ttf, font/otf |
wasm |
application/wasm |
pdf |
application/pdf |
zip, gz |
application/zip, application/gzip |
mp4, webm, mov |
video/mp4, video/webm, video/quicktime |
mp3, m4a, ogg, wav |
audio/mpeg, audio/mp4, audio/ogg, audio/wav |
| anything else | application/octet-stream |
Extensions are matched without regard to case. To send a different type, set it with header Content-Type ... in the route.
Conditional requests
When a request's If-Modified-Since is exactly the file's Last-Modified value, the answer is 304 Not Modified with no body. Browsers send back the value they got, so revalidation works without extra setup.
Byte ranges
A single range is supported, in the three usual forms:
| Range header | Sends, for a 1000-byte file |
|---|---|
bytes=0-99 |
The first 100 bytes |
bytes=500- |
From byte 500 to the end |
bytes=-100 |
The last 100 bytes |
The answer is 206 Partial Content with Content-Range. A range that starts past the end gets 416 Range Not Satisfiable with Content-Range: bytes */<size>. A request for several ranges at once, or a header in another unit, gets the whole file with 200. With If-Range, the range is honoured only while the date in it matches the file's Last-Modified. Videos seek and interrupted downloads resume.
Caching
Files up to 8 MB are kept in memory after the first read. The memory is shared with the PHP page cache and set with XTQW_CACHE_MB (default 1024). When it is full, the oldest entries go first. XTQW_CACHE=0 turns it off.
Each request still looks at the file's modification time, and the cache entry is tied to it, so a changed file is served fresh at once. A file changed in the last 3 seconds, which may still be being written, is read from disk and not cached.
Files over 8 MB are streamed from disk 64 KB at a time, so a large download costs little memory.
X-Xtq-Cache tells you which way a file went: HIT from memory, MISS read and stored, BYPASS read but not stored (recently changed, sent with a status other than 200 or 404, or the cache is off). Files over 8 MB and ranges carry no X-Xtq-Cache.
Compression
Files up to 8 MB with a text content type (text/* and anything containing javascript, json, xml or svg) and a size of at least 1024 bytes are compressed with Brotli or gzip when the client accepts it, Brotli first. The compressed copy is made once and kept with the cached file. These responses carry Vary: Accept-Encoding.
Files over 8 MB and range responses are sent as they are. compress off in a route does not affect files.
Methods
files answers every method the same way. A HEAD gets the headers without the body.
