trillium
Full documentation: https://cli.trillium.rs.
A single trillium binary that bundles the most useful pieces of the
trillium.rs web stack into a batteries-included HTTP
toolkit:
serve— a static file server (and drop-in reverse proxy)proxy— a reverse/forward proxy with upstream load-balancing and cachinggateway— a config-driven server combining static files + proxy across one or more listenersclient— a curl-like HTTP client that pretty-prints JSON and follows redirectsbench— a load generator with HDR-histogram latency statisticsdev-server— watch/rebuild/restart loop with browser live-reload for trillium apps (opt-in feature, Unix only)
TLS is built in (rustls by default), and with the default h3 feature the
servers also speak HTTP/3 over QUIC. Over TLS the client negotiates HTTP/2 via
ALPN; --http-version selects the protocol (HTTP/1.0 through HTTP/3) for
client and bench.
Install
This installs a binary named trillium. Run trillium --help, or
trillium <command> --help, for the full option list — the examples below
cover the common cases.
Most listening options also read from environment variables (HOST, PORT,
CERT, KEY, FORWARD, UPSTREAM), so they compose well with .env files
and process managers.
serve — static files
Serve the current directory on http://localhost:8080:
Pick a directory and port, and serve over your LAN:
Responses are compressed (gzip/brotli/zstd) automatically based on the client's
Accept-Encoding; pass --no-compress to turn that off.
Directory listings. By default a request for a directory with no index file
returns 404 Not Found. Pass -l / --directory-listing (or set
DIRECTORY_LISTING=1) to instead render an HTML index of the directory's
contents, with clickable column headers that sort by name, size, or modification
time:
It's off by default because it exposes file names and structure. Configuring an
--index file takes precedence — listings only appear for directories without
one.
Single-page apps & reverse proxying. --forward turns any request that
would 404 into a reverse proxy to another origin — perfect for serving a built
frontend while passing /api calls through to a backend:
Rate limiting. Cap requests per client network. Over-quota requests get
429 Too Many Requests with a Retry-After header, and every metered response
advertises the standard RateLimit / RateLimit-Policy headers:
Rates are written COUNT/WINDOW, where the window is s, min, or h.
proxy — reverse & forward proxy
Proxy all traffic to a single upstream:
Load-balance across several upstreams (default strategy is round-robin):
Strategies: round-robin, connection-counting, random, and forward (a
classic forward proxy, including CONNECT tunneling — pass no upstreams).
The proxy ships with an in-memory response cache (honoring caching headers),
compression, WebSocket upgrade passthrough, and the same --rate-limit
controls as serve:
# 1 GiB cache, evict entries idle for 5 minutes, throttle abusive clients
Use --no-cache to disable caching entirely. When an upstream is https://,
select a client TLS backend with --client-tls (-k/--insecure skips
verification for self-signed dev certs).
gateway — config-driven server
Where serve and proxy each do one thing from flags, gateway reads a
KDL config file and assembles the same building blocks —
static files, reverse proxy, redirects, header & HTML rewriting, compression,
rate limiting, TLS/h3 — into one or more listeners. It's a trillium-backed
caddy/nginx-lite.
A binding is one listener (host:port + optional TLS + per-binding HTTP
tuning). Within it, ordered route patterns dispatch by path to a stack of
directives:
compression true
rate-limit "100/min" burst=200
// Opt-in response cache for proxied upstreams (off unless declared, unlike
// `trillium proxy`). A bare `cache` node enables it with defaults.
cache {
capacity "256MiB"
time-to-idle "5m"
}
binding ":443" {
tls cert="./cert.pem" key="./key.pem"
http {
received-body-max-len "10MiB"
}
route "/api/*" {
// /api is stripped (the route pattern controls stripping, like `files`);
// give the upstream a base path to forward *with* the prefix instead.
proxy strategy="round-robin" {
upstream "http://127.0.0.1:9000"
upstream "http://127.0.0.1:9001"
}
}
route "/old/*" {
redirect "https://example.com/new" status=308
}
route "/*" {
headers {
add "X-Served-By" "trillium"
remove "Server"
}
files root="./public" index="index.html" directory-listing=true
}
}
Declare multiple binding blocks to run several listeners in one process; a
single Ctrl-C drains all of them gracefully. A bare :443 host binds all
interfaces (the nginx listen :80 convention). Routes match by path
specificity, for all HTTP methods.
HTML rewriting. A rewrite-html directive streams the response body through
lol-html, applying ordered mutations to the elements
matched by CSS selectors — inject tags, rewrite attributes, or strip nodes from
a static page or a proxied upstream. Only text/html responses are touched;
JSON and binary stream through untouched, so it's safe to drop in front of a
mixed proxy. CSS selectors are validated when the config loads (with a source
span pointing at any unsupported selector), not on the first request.
route "/*" {
proxy {
upstream "http://127.0.0.1:9000"
}
rewrite-html {
select "head" {
append "<script src=\"/analytics.js\" async></script>"
}
select "a[target=_blank]" {
set-attribute "rel" "noopener noreferrer"
}
select "img" {
set-attribute "loading" "lazy"
}
select ".legacy-banner" {
remove
}
select "title" {
set-text "Proxied by trillium"
}
}
}
Each select "css-selector" block holds an ordered list of element mutations.
Markup-valued ops (before, after, prepend, append, set-inner,
replace) insert their argument as HTML; set-text inserts HTML-escaped text.
The rest: set-attribute "name" "value", remove-attribute "name",
set-tag "div", remove (delete the element and its content), and unwrap
(drop the element's tags but keep its content).
Virtual hosting. Put host blocks inside a binding to dispatch by Host
header on a shared socket. Patterns are exact (example.com), wildcard
(*.example.com, any subdomain), or * (any). A request matching no host
block falls back to the binding's direct routes — which also catches requests
with no Host header (HTTP/1.0):
binding ":443" {
tls cert="./cert.pem" key="./key.pem"
host "app.example.com" {
route "/*" {
proxy {
upstream "http://127.0.0.1:9000"
}
}
}
host "*.static.example.com" {
route "/*" {
files root="./public"
}
}
// default vhost: unmatched hosts (and Host-less requests)
route "/*" {
redirect "https://example.com" status=308
}
}
client — make requests
A curl-like client that pretty-prints JSON, streams bodies, and follows redirects by default:
Send headers and a body (from the command line, a file, or stdin):
|
Other handy flags: --output-file to save the body, --dry-run to print the
request without sending it, --timeout/--no-timeout, and
--no-follow-redirects / --max-redirects to control redirect behavior.
bench — generate load
Closed-loop: 50 concurrent connections for 10 seconds (defaults):
Open-loop at a target arrival rate (switches to scheduled load, useful for measuring latency under a fixed offered rate):
Results are reported as an HDR-histogram latency summary. Add --json for a
machine-readable report on stdout, or --csv <path> for per-request timing
data. --connections, --requests, --warmup, and --timeout round out the
common knobs.
dev-server — live-reload for trillium apps
A watch / rebuild / restart loop with browser live-reload. It's feature-gated and Unix-only, so install it explicitly:
Run it from your app's project root and open the address it listens on:
It watches your source, rebuilds with cargo on change, restarts your binary,
and serves a reload-injecting proxy in front of it. The dev server adopts your
HOST/PORT (so you visit the same address you'd use in production) and runs
your app on a private port behind the proxy, passing it through as PORT. Use
--app-port if your app hardcodes its own port instead of reading PORT.
In a workspace it watches the crate it builds plus the workspace-local crates
it depends on, so editing a path-dependency library reloads the app using it.
Select what to build with cargo's own flags after a --:
When a build fails, the errors render as an overlay in the browser (the previous
build keeps running underneath). Click a file:line:column and it opens in your
editor — $EDITOR by default, or --editor "code --wait" — jumping to the
line. It also applies safe dev-build speedups (trimmed debug info + a fast linker
if one's installed); disable them with --no-fast.
See the dev-server guide for the full
details.
HTTPS
Provide a certificate and key to serve over TLS (and, with the default h3
feature, HTTP/3 over QUIC on the same port):
# or via the environment:
CERT=./cert.pem KEY=./key.pem
For local development, mkcert or
rcgen will generate a trusted cert/key pair. Test an HTTPS+h3 server with
curl -k https://localhost:8080.
Building from source & feature flags
Each subcommand is gated behind a Cargo feature, so you can build a smaller binary with only what you need:
| Feature | Subcommand | Default | Notes |
|---|---|---|---|
serve |
serve |
✅ | static file server + reverse proxy |
proxy |
proxy |
✅ | reverse/forward proxy with caching |
gateway |
gateway |
config-driven multi-listener server (KDL) | |
client |
client |
✅ | HTTP client |
bench |
bench |
✅ | load generator |
dev-server |
dev-server |
watch/rebuild/restart loop (Unix only) | |
grpc |
grpc |
generate Rust modules from .proto files |
TLS backends are selectable too: rustls (default), native-tls, and
openssl. The h3 feature (default) adds HTTP/3 over QUIC and implies
rustls.
# just the client, built against the system's native TLS
License
Licensed under either of MIT or Apache-2.0 at your option.