Cookbook / Multi-tenant apps

Host-based routing and custom domains

Serve storefronts from subdomains and customers' own domains, and keep the admin app on its own host.

A multi-tenant product often answers on many hosts: acme.stores.example.com for each customer, admin.example.com for staff, and customers' own domains. Server::host gives a host its own router, and ResolveHost looks up domains that are known only at runtime.

src/main.rs
use axeon::host::ResolveHost;
use axeon::{Router, Server, ServerError};

/// What a custom domain resolves to, from your database.
#[derive(Clone)]
struct Storefront {
    name: String,
}

fn main() {
    // A wildcard host: every subdomain of stores.example.com.
    let mut stores = Router::new();
    stores.get("/", |req| async move { format!("welcome to the {} store", req.subdomain().unwrap_or("?")) });

    // An exact host for staff.
    let mut admin = Router::new();
    admin.get("/", |_req| async { "admin console" });

    let mut app = Server::new();
    app.host("*.stores.example.com", stores);
    app.host("admin.example.com", admin);

    // Everything else is the main site.
    app.get("/", |_req| async { "the main site" });

    app.listen("127.0.0.1:3000").expect("server failed to start");

    // Customers' own domains are not known at startup. Resolve them per request with
    // `ResolveHost` (see `custom_domains` below), as middleware on the server.
}

#[allow(dead_code)]
fn custom_domains() -> Server {
    let mut app = Server::new();
    app.middleware(
        ResolveHost::new(|host: String| async move {
            // SELECT name FROM storefronts WHERE domain = $1 AND verified
            Ok::<_, ServerError>((host == "shop.acme.com").then(|| Storefront { name: "Acme".into() }))
        })
        .skip(["example.com", "www.example.com"]), // your own domains pass through
    );
    app.get("/", |req| async move { req.extension::<Storefront>().map(|s| format!("{} storefront", s.name)) });
    app
}
Terminal
curl http://127.0.0.1:3000/ -H 'host: acme.stores.example.com'    # welcome to the acme store
curl http://127.0.0.1:3000/ -H 'host: admin.example.com'          # admin console
curl http://127.0.0.1:3000/ -H 'host: anything-else.test'         # the main site

Notes#

  • A wildcard matches exactly one label: *.stores.example.com matches acme.stores.example.com, but not stores.example.com or a.b.stores.example.com. The label may hold only letters, digits, - and _.
  • A host's router owns it completely: a path it does not route is 404, not a fall back to the main site's routes or static files. Global middleware still runs for every host. Patterns are tried in the order you add them.
  • Behind a proxy, make sure it passes the original Host through.
  • ResolveHost caches lookups, including "unknown", for a minute, so a domain you remove keeps working that long. Unknown hosts answer 404.
  • Use req.host() for the normalised host name (lowercase, without port).