You are reading the docs for rws 18.1 The latest release is 18.8. Read this page for the latest release.

Config-Driven Proxy

Full annotated rws.config.toml reference for zero-code proxy configuration.

When rws.config.toml (or the file pointed to by RWS_CONFIG_FILE) contains at least one [[route]] or [[upstream]] section, rws boots in proxy mode automatically. No code changes required.

Minimal example

[[upstream]]
name = "api"
backends = ["localhost:3000"]

[[route]]
name = "api-proxy"

[route.match]
path = "/api/*"

[route.action]
type = "proxy"

[route.action.proxy]
upstream = "api"

[[upstream]] — backend pools

Each [[upstream]] block defines a named pool of HTTP or HTTPS backends.

[[upstream]]
name        = "api"                              # required; referenced by route actions
backends    = ["10.0.0.1:3000", "10.0.0.2:3000"] # plain HTTP (host:port)
strategy    = "round_robin"                       # "round_robin" | "random" | "ip_hash" | "least_connections"
  • round_robin (default) — cycles through live backends in order.
  • random — picks a live backend at random on each request.
  • ip_hash — hashes the client IP so the same client always reaches the same backend (useful for WebSocket/SSE stickiness without shared session state).
  • least_connections — routes to whichever live backend currently has the fewest in-flight requests through this route.

An unrecognized or empty strategy value falls back to round_robin.

Performance

A proxy route with no [route.middleware] and no global [middleware] is forwarded without blocking a thread — HTTP and HTTPS upstreams alike, including tls_ca_file ones: the request waits for the backend on its connection's own task, over pooled keep-alive connections. Routes with middleware use a thread from the blocking pool for the round trip — still pooled, slower under heavy concurrency. See Benchmarks.

TLS (HTTPS) upstreams

Prefix backend addresses with https:// to connect over TLS. Certificate verification uses the WebPKI root store (same CA bundle as browsers). TLS connections are kept alive and reused, like plain ones — one handshake per connection, not per request — and large or chunked responses are streamed.

[[upstream]]
name     = "secure-api"
backends = ["https://api.example.com:443", "https://api2.example.com:443"]

[upstream.health_check]
path          = "/healthz"
interval_secs = 10

For internal services behind a private CA or a self-signed certificate, name the CA to trust with tls_ca_file. It replaces the public roots for that upstream — requests and health checks alike — so only certificates issued by that CA are accepted:

[[upstream]]
name        = "billing"
backends    = ["https://billing.internal:8443"]
tls_ca_file = "/etc/rws/internal-ca.pem"

A CA file that can't be read fails closed: the upstream answers 502 and the error is logged, rather than falling back to the public roots. rws --check reports it. A reload re-reads the file, so a rotated CA takes effect on SIGHUP.

Feature requirement

HTTPS upstreams require the http-client or http2 feature — both are included in the default (http3) build and the http2-only build. The http1-only build (--no-default-features --features http1) returns 502 for HTTPS backends and logs an error.

Backend URL format summary

Format Protocol Default port
host:port Plain TCP / HTTP — (required)
http://host:port Plain TCP / HTTP 80
https://host:port TLS / HTTPS 443

[upstream.health_check] — automatic health monitoring

Add a nested [upstream.health_check] table to enable background health checking. Unhealthy backends are removed from the live rotation until they recover.

[[upstream]]
name     = "api"
backends = ["10.0.0.1:3000", "10.0.0.2:3000", "10.0.0.3:3000"]

[upstream.health_check]
path                 = "/healthz"   # GET path sent to each backend
interval_secs        = 30           # how often to probe (default: 30)
timeout_ms           = 5000         # connect + read timeout per probe (default: 5000)
healthy_threshold    = 2            # consecutive successes before marking live (default: 2)
unhealthy_threshold  = 3            # consecutive failures before marking dead (default: 3)

See Health Checks for the full implementation details.

[[route]] — routing rules

Routes are evaluated in declaration order; the first match wins.

[[route]]
name = "my-route"    # informational label; no functional effect

[route.match] — matching criteria

All fields are optional. Omitting a field means "match anything".

[route.match]
host         = "api.example.com"     # SNI hostname (TLS) or Host header (plain HTTP)
path         = "/api/*"              # prefix match when ending with *, exact match otherwise
method       = "POST"                # HTTP method (case-insensitive)
content_type = "application/json*"   # Content-Type prefix match when ending with *

Path matching rules:

Pattern Matches
/api/* Any path starting with /api/ (prefix match)
/api/ping Only /api/ping (exact match)
(omitted) All paths

Paths are normalized before any route is matched, and sent upstream in that form (since 17.113.1): percent-encoded unreserved characters are decoded (/%61dmin → /admin; %2F and other escapes stay encoded), repeated slashes collapse (//admin → /admin) and . / .. segments are resolved (/x/../admin → /admin). So a route that protects /admin/* can't be skipped by spelling the path differently. host matches ignoring case, and ignoring the port unless the route names one.

[route.action] — what to do on match

The type field selects the action. An action's keys may also sit directly under [route.action] instead of in [route.action.<type>] (type = "proxy" plus upstream = "api" in the same table); if a key appears in both, the nested table wins.

type = "proxy" — forward to an upstream

[route.action]
type = "proxy"

[route.action.proxy]
upstream           = "api"      # upstream name defined in [[upstream]]
connect_timeout_ms = 5000       # TCP connect timeout in ms (default: 5000)
read_timeout_ms    = 30000      # response read timeout in ms (default: 30000)
strip_path_prefix  = "/api"     # strip this prefix before forwarding (optional)
add_path_prefix    = "/v2"      # prepend this prefix before forwarding (optional)

type = "grpc" — forward gRPC to an HTTP/2 upstream

[route.action]
type = "grpc"

[route.action.grpc]
upstream           = "grpc-svc"
connect_timeout_ms = 5000
read_timeout_ms    = 30000

type = "redirect" — HTTP redirect

[route.action]
type = "redirect"

[route.action.redirect]
location = "https://example.com$path"   # $path is replaced with the request URI
status   = 301                          # 301, 302, 307, or 308 (default: 301)

type = "respond" — fixed response

[route.action]
type = "respond"

[route.action.respond]
status       = 200
body         = "{\"status\":\"ok\"}"
content_type = "application/json"

type = "static" — serve a directory

[route.action]
type = "static"

[route.action.static]
root  = "/var/www/site"        # absolute or relative to the process working directory
index = ["index.html"]         # tried in order for directory requests; defaults to ["index.html"]

Requests are resolved against root, independent of the server process's working directory. Any request path containing a .. segment (before or after percent-decoding) is rejected with 403; a request that resolves to a missing file returns 404. Directory requests (or any path resolving to a directory) try each index entry in order and 404 if none exist. MIME type is detected from the file extension, matching the built-in static-file controller.

[route.middleware] — per-route middleware

Middleware is applied only to requests that match this route.

Rate limiting

[route.middleware.rate_limit]
max_requests = 100    # requests allowed per window (default: 1000)
window_secs  = 60     # sliding window size in seconds (default: 60)

Any sub-table can also be written inline under [route.middleware] — rate_limit = { max_requests = 100, window_secs = 60 } is the same as the [route.middleware.rate_limit] table above.

Authentication

Three types, all under [route.middleware.auth]:

Bearer token — always available, no extra feature:

[route.middleware.auth]
type      = "bearer"
token_env = "API_TOKEN"    # environment variable holding the expected token

Incoming requests must include Authorization: Bearer <value of API_TOKEN>. Returns 401 Unauthorized on mismatch.

JWT (HS256) — requires the auth feature; wires into JwtLayer:

[route.middleware.auth]
type       = "jwt"
secret_env = "JWT_SECRET"   # environment variable holding the HS256 signing secret

HTTP Basic (htpasswd file) — requires the auth feature; wires into BasicAuthLayer::from_htpasswd_file:

[route.middleware.auth]
type          = "basic"
htpasswd_file = ".htpasswd"

The htpasswd file supports plain-text and {SHA256} (rws's own scheme) entries only — not Apache's real {SHA}/$apr1$/bcrypt. See the auth feature page for the exact file format and how to generate a {SHA256} entry with openssl.

Fails closed, not silently

Auth fails closed. If token_env/secret_env/htpasswd_file is unset, empty, or unreadable — or the binary was built without the auth feature — that route answers 503 Service Unavailable (logged to stderr at startup) instead of being served unprotected. Other routes keep working. Check your server's startup logs after changing [route.middleware.auth].

IP filter

[route.middleware.ip_filter]
allow = ["10.0.0.0/8", "192.168.1.100", "2001:db8::/32"]   # allowlist (CIDR or exact, IPv4 or IPv6)
deny  = ["1.2.3.4"]                                          # denylist (ignored when allow is set)

Request rewriting

[[route.middleware.rewrite.request]]
type  = "header_set"
name  = "X-Real-IP"
value = "client"

[[route.middleware.rewrite.request]]
type   = "uri_strip_prefix"
prefix = "/internal"

Request rule types: header_set (name, value), header_remove (name), uri_set (value), uri_strip_prefix / strip_prefix and uri_add_prefix / add_prefix (prefix, or value).

Response rewriting

[[route.middleware.rewrite.response]]
type  = "header_set"
name  = "Cache-Control"
value = "no-store"

[[route.middleware.rewrite.response]]
type = "body_replace"
from = "staging.internal"
to   = "example.com"

[[route.middleware.rewrite.response]]
type   = "status"
code   = 410
reason = "Gone"

Response rule types: header_set (name, value), header_remove (name), status (code, reason), body_replace (from, to). An unknown type is ignored (and reported by rws --check).

Response caching

[route.middleware.cache]
ttl_secs = 60                 # default: 60
vary_by  = ["Accept-Language"] # request headers added to the cache key (optional)

Wraps the route in an in-memory CacheLayer (up to 1000 entries). It is the innermost route middleware, so cached responses still pass through rewrite, auth, rate limit and IP filter.

Per-route timeout

[route.middleware]
timeout_ms = 5000   # 504 if this route (including its other middleware) doesn't respond within 5s

0/absent means no route-specific timeout. Bounds this route's total time — every other middleware layer runs inside it. See Timeouts for the underlying mechanism and its one honest limitation (a synchronous handler that ignores its deadline keeps running in the background; only the client's wait is bounded).

Per-route max body size

[route.middleware]
max_body_size = 65536   # 413 if this route's request body exceeds 64 KiB

0/absent means no route-specific limit. This is a stricter, additional cap on top of the global RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES (see Forms & Uploads) — not a substitute for it. It's checked after this route has already been matched, which means the body was necessarily already read in full to build the Request that route matching itself required; use it to keep one route (e.g. a small JSON API) stricter than the global ceiling a separate upload route needs, not as protection against unbounded memory use — the global limit is what already provides that, for every route.

The limit is the outermost layer of the route — checked before timeout_ms, auth and the rest — and a rejected request gets:

HTTP/1.1 413 Payload Too Large
Content-Type: text/plain

request body of 100000 bytes exceeds this route's 65536-byte limit

L4 proxy sections

[[tcp_proxy]] — raw TCP tunnel

[[tcp_proxy]]
name               = "pg"
listen             = "0.0.0.0:5432"
backends           = ["db-1:5432", "db-2:5432"]
connect_timeout_ms = 5000

[[udp_proxy]] — UDP datagram proxy

[[udp_proxy]]
name             = "dns"
listen           = "0.0.0.0:53"
backends         = ["8.8.8.8:53", "8.8.4.4:53"]
reply_timeout_ms = 2000
buffer_size      = 65536

[[ws_proxy]] — WebSocket proxy

[[ws_proxy]]
name               = "chat"
listen             = "0.0.0.0:8080"
backends           = ["chat-backend:9000"]
connect_timeout_ms = 5000
read_timeout_ms    = 30000

[ws_proxy.health_check]   # optional — same keys as [upstream.health_check]
path = "/healthz"

L4 and WebSocket listeners are started once at boot; a SIGHUP reload does not add, remove or change them.

Global middleware

Middleware that applies to every request goes in a top-level [middleware] section (same field structure as [route.middleware]). It wraps the whole router: matched routes and requests that fall through to the static files, health checks and metrics. Per-route middleware runs inside it.

[middleware.rate_limit]
max_requests = 500
window_secs  = 60

Using it from Rust

The rws binary wires this up automatically. An application embedding the crate can build the same proxy from the config file:

use rust_web_server::proxy_config::{build_from_file, ProxyConfig};

if ProxyConfig::is_proxy_mode() {
    // `app` is a ConfigDrivenApp (Application + Clone); `_listeners` are the
    // [[tcp_proxy]] / [[udp_proxy]] / [[ws_proxy]] threads.
    let (app, _listeners) = build_from_file();
    // pass `app` to Server::run / run_tls / run_quic
}

Reloading without a restart

SIGHUP and POST /admin/config/reload reload [[route]], [[upstream]] and [middleware] — see Hot Reload. An embedding application can drive the same reload itself, e.g. from its own admin endpoint or a file watcher:

use rust_web_server::proxy_config::{build_from_file, ReloadOutcome};

let (app, _listeners) = build_from_file();
let reloader = app.clone();          // clones share the live routes

// later, after rws.config.toml changed:
match reloader.reload_from_str(&std::fs::read_to_string("rws.config.toml")?) {
    Ok(ReloadOutcome::Reloaded { routes, warnings }) => println!("{routes} routes live; {warnings:?}"),
    Ok(ReloadOutcome::Unchanged) => {}
    Err(report) => eprintln!("kept the running routes:\n{}", report.render("rws.config.toml")),
}

A file with errors never replaces the running routes. [[tcp_proxy]], [[udp_proxy]] and [[ws_proxy]] listeners are bound once at startup; a reload that changes them returns a warning. To run your own code on every reload, register a hook with config_reload::on_reload(|| ...).

Full annotated example

# rws.config.toml — full proxy setup

# ── upstreams ──────────────────────────────────────────────────────────────────

[[upstream]]
name     = "api"
backends = ["api-1:3000", "api-2:3000"]
strategy = "round_robin"

[upstream.health_check]
path                = "/healthz"
interval_secs       = 15
timeout_ms          = 3000
healthy_threshold   = 2
unhealthy_threshold = 3

[[upstream]]
name     = "grpc-svc"
backends = ["grpc-1:50051"]

# ── routes ─────────────────────────────────────────────────────────────────────

[[route]]
name = "maintenance-page"

[route.match]
host = "down.example.com"

[route.action]
type = "respond"

[route.action.respond]
status       = 503
body         = "Under maintenance"
content_type = "text/plain"

[[route]]
name = "grpc"

[route.match]
content_type = "application/grpc*"

[route.action]
type = "grpc"

[route.action.grpc]
upstream = "grpc-svc"

[[route]]
name = "api"

[route.match]
path = "/api/*"

[route.action]
type = "proxy"

[route.action.proxy]
upstream          = "api"
strip_path_prefix = "/api"

[route.middleware.rate_limit]
max_requests = 200
window_secs  = 60

[route.middleware.auth]
type      = "bearer"
token_env = "API_SECRET"

[[route]]
name = "redirect-www"

[route.match]
host = "example.com"

[route.action]
type = "redirect"

[route.action.redirect]
location = "https://www.example.com$path"
status   = 301

# ── L4 proxies ─────────────────────────────────────────────────────────────────

[[tcp_proxy]]
name     = "postgres"
listen   = "0.0.0.0:5432"
backends = ["pg-primary:5432"]

[[udp_proxy]]
name     = "dns"
listen   = "0.0.0.0:53"
backends = ["8.8.8.8:53", "8.8.4.4:53"]

Edit this page on GitHub