Volga
Home
Agent Skill
API Docs
GitHub
  • English
  • Русский
Home
Agent Skill
API Docs
GitHub
  • English
  • Русский
  • Home
  • Getting Started

    • Quick Start
    • Handlers
    • Route Parameters
    • Query Parameters
    • Route Groups
  • Requests & Responses

    • Headers
    • Handling JSON
    • Handling Form Data
    • Working with Files
    • Multipart Responses
    • Raw Request Body
    • Cookies
    • Validating Requests
  • Middleware & Infrastructure

    • Basic Middleware
    • Custom Middleware
    • Parameterized Middleware
    • Response Compression
    • Request Decompression
    • CORS (Cross-Origin Resource Sharing)
    • Static Files
    • Rate Limiting
    • Configuration Files
    • OpenAPI
  • Security & Access

    • Authentication and Authorization
    • OAuth 2.1 & OpenID Connect
    • OAuth 2.1 Client
    • Machine-to-Machine Grants
    • DPoP — Sender-Constrained Tokens
  • Reliability & Observability

    • Global Error Handling
    • Tracing & Logging
    • Request cancellation
    • Graceful Shutdown
  • Protocols & Realtime

    • HTTP Versions and Methods
    • HTTPS
    • WebSockets
    • Server-Sent Events (SSE)
  • Advanced Patterns

    • Dependency Injection
    • Custom Handling of HEAD, OPTIONS, and TRACE Methods
  • Agent Skill

Static Files

Volga supports serving static files with features such as directory browsing, a configurable index file name, path prefixing, a content root folder, and a special fallback file.

Prerequisites

Dependencies

If you're not using the full feature set, you need to enable the static-files feature in your Cargo.toml:

[dependencies]
volga = { version = "...", features = ["static-files"] }

The static-files feature implies middleware, since middleware is what serves the files.

Folder Structure

Let's assume we have the following folder structure:

project/
│── static/
│   ├── index.html
│   ├── style.css
│   ├── script.js
│── src/
│   ├── main.rs
│── Cargo.toml

Basic Static File Server

After creating html, css, and js files, you can set up a minimal static file server in main.rs:

use volga::App;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new()
        .with_host_env(|env| env.with_content_root("/static"));

    // Enables serving static files
    app.use_static_assets();

    app.run().await
}

with_content_root() sets the folder the files are served from. The path is used exactly as written, so a relative one — with_content_root("static") — resolves against the process's working directory, which is what a project laid out like the tree above wants. The default is the literal /static.

Tips

A leading slash makes the path absolute on Unix, so "/static" means /static at the filesystem root, not project/static. Drop it unless that is what you meant. A content root of / is reported on startup.

Then use_static_assets() answers GET and HEAD requests from the content root:

  • / → the index file (index.html by default)
  • /{any path} → the file of that name under the content root, at any depth

Tips

On 0.9.x this call is spelled map_static_assets(). It registers no route — see Middleware, not routing below — so it lost the map_ prefix, which in this crate means a route was registered. Nothing else about it differs.

Middleware, not routing

The static file server is a middleware rather than a set of routes. It reads the request target, answers it from the content root when something is there, and declines otherwise. The router knows nothing about static content.

What follows from that:

  • No startup walk and no depth limit. The content root is read when a request asks for something, not walked while the server starts, so a directory created while the server is running is served like any other.
  • Nothing is registered in the router. No route is shadowed by static content, none of it shows up in the route listing printed at startup, and none of it has to be described in an OpenAPI spec. Static files and a dynamic route coexist: app.map_get("/{id}", ..) works beside use_static_assets().
  • A file answers before a route does. The mount is the first thing a GET or HEAD under it reaches, so a file that exists on disk is served even where a route was mapped for the same path. Any other method, and any path with nothing behind it, reaches routing.
  • Position in the pipeline matters — see below.

A request that nothing under the content root answers goes on to routing, so map_fallback_to_file() answers it and an SPA shell works as usual.

Where to put the call

Because the mount is middleware, where it sits in the pipeline is where you registered it:

use volga::App;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new()
        .with_cors(|cors| cors.with_any_origin().with_any_header().with_any_method());

    app.use_compression(); // files are compressed
    app.use_cors();        // files carry the CORS headers

    app.use_static_assets();

    // Anything registered below runs only for requests
    // that were not answered from disk
    app.with(|next| async move { next.await });

    app.run().await
}

Register the mount after use_compression() to have the files compressed, after use_cors() to have the headers on them, and before anything that should not run for a request answered from disk.

Path resolution

The request target is read the way the router reads it: split into segments, each one percent-decoded, and the mount's prefix compared with the decoded segments — so a mount and a route under one prefix answer the same requests. /st%61tic/app.css and //static/app.css are both served under /static. A + in a request target is the literal character, not a space: a request target is not a form body.

What is left is resolved from its ordinary path components alone. A ., a .., an encoded separator (%2F) or an embedded NUL is declined rather than dropped or looked up, so traversal is refused by construction. A symlink under the content root that points outside of it — the one case a request target cannot describe — is still caught and answered 403.

A target that does not decode — a malformed %XX escape, or one that is not UTF-8 — is left to the router, which answers it 400 through the error handler, so map_err and problem details apply to it as to any other malformed path.

Write the prefix as its text

A prefix is compared with decoded segments, so it is written as the text it spells: app.group("/docs v1", ..), not "/docs%20v1". A literal segment carrying a percent-escape panics where the mount is registered, as it does in a route.

Fallback

To serve a custom fallback file (e.g., 404.html) for a GET or HEAD request that neither a file on disk nor a route answers, use map_fallback_to_file().

use volga::App;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new()
        .with_host_env(|env| env
            .with_content_root("/static")
            .with_fallback_file("404.html"));

    // Enables serving static files
    app.use_static_assets();

    // Enables fallback to 404.html
    app.map_fallback_to_file();

    app.run().await
}

Since fallback files are disabled by default, we explicitly set the 404.html file using with_fallback_file("404.html").

A more concise version of the above code is:

use volga::App;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new()
        .with_host_env(|env| env
            .with_content_root("/static")
            .with_fallback_file("404.html"));

    // Enables serving static files 
    // and fallback to 404.html
    app.use_static_files();

    app.run().await
}

The use_static_files() method combines use_static_assets() and map_fallback_to_file(). However, the fallback feature is only enabled if a fallback file is specified.

Tips

Point with_fallback_file() at index.html to render every client-side URL from the one shell — the usual single-page application setup.

How the file is served

The fallback file is served by a GET route covering the mount's prefix and {*path} below it — since 0.11.1 it is an ordinary route in the router, which settles three questions worth knowing about:

  • It answers GET and HEAD only. The shell is how a client-side URL renders the application, and a browser navigates with GET. Anything else — a POST to a mistyped API path, an OPTIONS, a PUT to the path of a file — is answered 405 with Allow: GET,HEAD, the same answer any route gives a method it lacks. That is what keeps a mistyped write from getting an HTML page and a 200, and failing in the client at res.json() instead of where it was made.
  • A route of yours answers before the shell. A literal or a parameter route is read before a catch-all at every position, so map_get("/health", ..) keeps answering with a root mount in place, in either registration order. A GET route mapped by hand at one of the shell's own positions — map_get("/{*rest}", ..) beside a root mount — simply takes that position over.
  • It is not the application's fallback slot. map_fallback() and map_fallback_to_file() no longer replace each other: the shell answers under the mount's prefix, and map_fallback answers whatever lies outside it.

Like the files themselves, the shell route stays out of the route listing printed at startup and out of the OpenAPI document.

Serving an API from the same server

Under a root mount the shell covers every GET path, so map_fallback is left with nothing to answer — the shell gets there first, and other methods are answered 405. To keep an API's misses in the API's own shape, give its group a fallback of its own: the router prefers it for sitting deeper than the shell.

use volga::{App, http::Uri, not_found, ok};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new()
        .with_host_env(|env| env
            .with_content_root("static")
            .with_fallback_file("index.html"));

    app.group("/api", |api| {
        api.map_get("/users", || async { ok!("users") });

        // GET /api/uesrs -> 404 JSON, not the SPA shell
        api.map_fallback(|uri: Uri| async move {
            not_found!("no endpoint at {}", uri.path())
        });
    });

    // Every other unknown path renders the application
    app.use_static_files();

    app.run().await
}

Serving Under a Prefix

A route group keeps the mount to one part of the URL space:

use volga::App;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new();

    // Files are served under /static/* only
    app.group("/static", |g| {
        g.use_static_assets();
    });

    // Reached as usual: the mount declines everything outside its prefix
    app.map_get("/{id}", |id: i32| async move { id });

    app.run().await
}

The group's middleware — wrap, with, filter, map_ok, authorize, a rate limiter — wraps the files this mount serves, exactly as it wraps the routes the group registered, nested groups included.

RouteGroup::use_static_files() mounts the files and the fallback file under that prefix, which since 0.11.1 is where the shell answers and nowhere else — one server can host a front end under /app and keep the rest of its URL space to itself:

use volga::{App, ok};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new()
        .with_host_env(|env| env
            .with_content_root("static")
            .with_fallback_file("index.html"));

    // GET /app/assets/app.js -> the file
    // GET /app/settings      -> index.html
    app.group("/app", |g| {
        g.use_static_files();
    });

    // GET /health -> the route; GET /elsewhere -> 404, not the shell
    app.map_get("/health", || async { ok!("healthy") });

    app.run().await
}

The group's middleware runs around the shell as it does around the files.

Two limits of a group mount

  • The prefix must be literal. app.group("/{tenant}", |g| g.use_static_files()) does not serve static files and says so at startup: a mount matches the request target as it is written, while a parameter is matched by the router, which knows nothing about the mount.
  • A group's CORS policy does not reach the files, the fallback file included. A policy is resolved from the route that matched, and a file is served without one, so the policy that applies is the application's — configured with with_cors(). For the same reason a preflight aimed at a file's path is answered as one for an unmatched path.

Directory Browsing

Like fallback files, directory browsing is disabled by default. You can enable it using with_files_listing(). However, this is not recommended for production environments — an application that leaves it on in a release build says so on startup.

use volga::App;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new()
        .with_host_env(|env| env
            .with_content_root("/static")
            .with_fallback_file("404.html")
            .with_files_listing());

    // Enables serving static files 
    // and fallback to 404.html
    app.use_static_files();

    app.run().await
}

Caching

Every static file is served with an ETag, a Last-Modified and a Cache-Control, and a conditional request that still matches is answered 304.

Which policy a file gets is decided by the role the name it is addressed by gives it, not by the file itself:

RoleWhat it isCache-Control
assetevery file addressed by a content-hashed name — assets/index-a1b2c3.jsmax-age=86400, public, immutable
shellthe index file and the fallback file, addressed by a stable nameno-cache

An asset never changes under the same URL, so it is taken on trust and never revalidated. The shell does change under the same URL, so it is revalidated on every navigation — which costs a 304 and no body while it is unchanged, and picks a deploy up on the next request rather than a day later.

Conditional requests follow RFC 9110: If-None-Match wins over If-Modified-Since when both are present, Last-Modified is compared at whole-second precision so a client's echo of it still matches, validators are read for GET and HEAD only, and a 304 carries the same Cache-Control as the full response.

Configuring the policies

Both roles are configured on HostEnv. The builders receive the policy currently in effect, so narrowing a single directive does not mean restating the rest:

use volga::App;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new()
        .with_host_env(|env| env
            .with_content_root("/static")
            // Assets stay fresh for an hour instead of a day
            .with_asset_cache_control(|cc| cc.with_max_age(60 * 60))
            // The shell is never stored at all
            .with_shell_cache_control(|cc| cc.with_no_store()));

    app.use_static_files();

    app.run().await
}

with_asset_cache_control() and with_shell_cache_control() need 0.9.11 or newer, and are read back with asset_cache_control() / shell_cache_control(). The two defaults are named by the CacheControl::ASSET and CacheControl::SHELL constants for anyone building a policy from scratch; CacheControl::EMPTY is the const equivalent of CacheControl::default().

To put the shell on the asset policy anyway — a deployment that never reuses a shell URL:

use volga::App;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new()
        .with_host_env(|env| env
            .with_shell_cache_control(|cc| cc
                .with_max_age(86400)
                .with_public()
                .with_immutable()));

    app.use_static_files();

    app.run().await
}

Where the ETag comes from

A tag is derived either from what the filesystem says about a file or from the bytes in it, and the role decides which — a tag that is never read does not need to cost a read:

RoleDefaultWhy
assetETagSource::Metadatait is served immutable, so a client never revalidates it and the tag is never consulted
shellETagSource::Contentit is served no-cache, so the tag is what decides between a 304 and a full body on every navigation

Metadata hashes the file's byte length and the whole-second part of its mtime — what nginx, Apache and ASP.NET Core tag with, and free, because the server has already made that stat. Content hashes the bytes themselves: identical wherever one build is deployed, different as soon as a single byte is.

That difference is why the shell defaults to Content. Two versions of one file carry the same Metadata tag whenever they have the same length and an mtime in the same second, which is the ordinary case for the index.html of a content-hashed build: its <script src="/assets/index-a1b2c3.js"> keeps its byte length across deploys, and a deployment that pins timestamps (SOURCE_DATE_EPOCH, tar -p, rsync -t) keeps the second too. A client holding the old tag would then be answered 304 for changed content.

Both sources are configurable, on the same HostEnv:

use volga::{App, headers::ETagSource};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new()
        .with_host_env(|env| env
            // Assets that revalidate rather than being taken on trust,
            // so their tags have to hold
            .with_asset_cache_control(|cc| cc.with_max_age(60))
            .with_asset_etag(ETagSource::Content)
            // Back to the cheaper tag, for a deployment that never
            // rewrites the shell in place
            .with_shell_etag(ETagSource::Metadata));

    app.use_static_files();

    app.run().await
}

with_asset_etag() and with_shell_etag() need 0.10.1 or newer, and are read back with asset_etag() / shell_etag(). The pairing to keep in mind is with Cache-Control: narrow CacheControl::ASSET so assets start revalidating, and ETagSource::Content is what makes their answers trustworthy.

A content tag costs one read per file version, not per request: it is remembered against the file's length and its mtime at full precision, plus whatever the platform can say about the file rather than its contents — the inode and change time on Unix, the creation time on Windows. A restart starts from an empty cache, and the cache is bounded, so a content root with a file per user does not grow an entry per file and keep it.

Tips

The ETag is weak whatever it is derived from. RFC 9110 §8.8.1 reserves strong validation for octet-equality of the representation that is actually sent, and the compression middleware may re-encode a body after the static file server has set the header.

For a handler attaching one of these policies to a response of its own rather than configuring a server, CacheControl::asset() and CacheControl::shell() are the same two defaults as ready Header<CacheControl> presets, alongside no_cache(), public() and the rest.

Host Environment

For more advanced scenarios, you can use the HostEnv struct, which represents the application's host environment. Using HostEnv directly makes it easier to switch between environments.

Here's how you can achieve the same configuration with HostEnv:

use volga::{App, File, app::HostEnv};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let env = HostEnv::new("/static")
        .with_fallback_file("404.html")
        .with_files_listing();

    let mut app = App::new()
        .set_host_env(env);

    // Enables serving static files 
    // and fallback to 404.html
    app.use_static_files();

    // Handles new static file uploads
    app.map_post("/upload", |file: File, env: HostEnv| async move {
        let root = env.content_root();
        file.save(root).await
    });

    app.run().await
}

Additionally, HostEnv can be extracted in middlewares and request handlers.

For a full example, see this repository.

Last Updated: 9/27/26, 5:05 PM
Prev
CORS (Cross-Origin Resource Sharing)
Next
Rate Limiting