Static Files
An App serves classpath:/public/* at the root without being asked to, and staticFiles("/assets") names a different directory.
Every response carries an ETag and Last-Modified derived from the resource, so a reload comes back as a bodyless 304 instead of the file again.
A modification time before 2000 is a stamp that a reproducible build puts on every file alike.
Jib, for one, stamps every file of an image with 1970-01-01T00:00:01Z.
Such a file is tagged by a CRC-32 of its content instead, and carries no Last-Modified.
The content is read for that tag once per version of the file, which a directory root tells by the file’s change time as well, since a copy such as cp -a or rsync -a keeps the stamp but cannot keep the change time.
A one-character edit that leaves the length unchanged therefore still reaches the browser in the next release.
The default Cache-Control: no-cache means "cache it, but check with me first", which is right for a file whose name stays the same when its content changes.
For a fingerprinted name, set a long maxAge:
app.staticFiles(StaticFiles.classpath("/public")
.hostedPath("/assets") // classpath:/public/* at /assets/*
.maxAge(Duration.ofDays(365))); // only when the name carries a content hash
hostedPath takes a prefix, not a pattern: "/assets" covers everything under it, and "/assets/" throws IllegalArgumentException.
A filter path and Cors.forPath are the opposite, a pattern that needs the trailing to cover what is under it.
Routes are matched first, so a route can shadow a file. Directories are never served.
A static file bypasses beforeRoute and afterRoute, since no route matched.
beforeRequest("/admin/*", …) can guard files served under /admin before static-file lookup.
A response filter does run on a static file, as do CORS, compression, and the security headers.
The file is opened only when its body is written, so a filter may replace the response or throw without leaving the file open.
A HEAD and a 304 likewise open nothing, whether the file sits in a directory, on an exploded classpath, or inside a jar.
Under app.gzip(), a HEAD that would be compressed carries no Content-Length, since only compressing the whole file would tell it.
The first request for a file with a stamped time is the one exception: it reads the file once to work out the tag.
A directory on disk
StaticFiles.directory(path) serves files that are not on the classpath, such as user uploads, a volume mounted beside the jar, or a directory some separate build writes into:
app.staticFiles(
StaticFiles.classpath("/public"), // classpath:/public/* at /*
StaticFiles.directory(Path.of("/srv/uploads")) // /srv/uploads/* at /uploads/*
.hostedPath("/uploads"));
staticFiles(…) takes as many roots as you have, and reads them in the order given.
The first root that holds the file answers, and the rest are not asked.
Everything else is the same either way: the same validators, the same conditional requests, the same Cache-Control, and the same hostedPath.
The root is read per request rather than at construction, so a volume that is mounted after the application starts needs no restart, and one that is never mounted answers 404 instead of failing to boot.
Called with no argument at all, staticFiles() serves nothing and leaves every path to routing.
A pre-compressed sibling
precompressed() answers from a .br or .gz file that a build left next to the asset.
gzip() on its own deflates the same unchanging asset again on every request that asks for it, and cannot answer brotli at all, because the JDK ships no encoder:
app.gzip(); // still deflates what has no sibling
app.staticFiles(StaticFiles.classpath("/public")
.precompressed()); // app.css.br / app.css.gz answer app.css
Brotli wins where both siblings exist and the client takes both.
Brotli is the smaller of the two, and no other part of core can produce it.
The answer describes the original either way.
The Content-Type is the original’s, since a .gz extension is the encoding and not the type.
The ETag and Last-Modified are the original’s as well, so a browser revalidating across encodings keeps its 304.
That tag is marked weak on an encoded answer, for the same reason gzip() marks its own.
An asset with no sibling, or a client that reads neither encoding, is answered exactly as before, gzip() included.
A sibling older than the file it sits next to is passed over: a build that did not rerun left it behind, and serving it would serve content that no longer exists.
Every answer from a root with precompressed() on carries Vary: Accept-Encoding, sibling found or not, so a shared cache never hands encoded bytes to a client that cannot read them.
precompressed() is off until you call it, because a .gz left over from an older pipeline is a file nobody meant to serve.
Staying inside the directory
A request is answered only when the file it resolves to, with every symbolic link followed, is a regular file whose real path lies under the root’s own real path.
A directory root is a path-traversal surface that a classpath lookup does not have, so the guard is explicit and does not lean on the container.
A .. anywhere in the path is refused before the guard runs, and a symbolic link pointing out of the root is refused by it.
A request the guard refuses is treated as a file this root does not have, so routing carries on and the answer is the usual 404.
The 404 does not say whether the file was refused or absent.