Config-Driven Proxy
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 actionsbackends = ["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.
TLS (HTTPS) upstreams
Prefix backend addresses with https:// to connect over TLS. Certificate verification uses the system WebPKI root store (same CA bundle as browsers).
[[upstream]]name = "secure-api"backends = ["https://api.example.com:443", "https://api2.example.com:443"]
[upstream.health_check]path = "/healthz"interval_secs = 10Backend 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 backendinterval_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 otherwisemethod = "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 |
[route.action] — what to do on match
The type field selects the action.
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 = 5000read_timeout_ms = 30000type = "redirect" — HTTP redirect
[route.action]type = "redirect"
[route.action.redirect]location = "https://example.com$path" # $path is replaced with the request URIstatus = 301 # 301, 302, 307, or 308 (default: 301)type = "respond" — fixed response
[route.action]type = "respond"
[route.action.respond]status = 200body = "{\"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 directoryindex = ["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)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 tokenIncoming 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 secretHTTP 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.
IP filter
[route.middleware.ip_filter]allow = ["10.0.0.0/8", "192.168.1.100"] # allowlist (CIDR or exact)deny = ["1.2.3.4"] # denylistRequest rewriting
[[route.middleware.rewrite.request]]type = "header_set"name = "X-Real-IP"value = "client"
[[route.middleware.rewrite.request]]type = "uri_strip_prefix"prefix = "/internal"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"Per-route timeout
[route.middleware]timeout_ms = 5000 # 504 if this route (including its other middleware) doesn't respond within 5s0/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 KiB0/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.
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 = 2000buffer_size = 65536[[ws_proxy]] — WebSocket proxy
[[ws_proxy]]name = "chat"listen = "0.0.0.0:8080"backends = ["chat-backend:9000"]connect_timeout_ms = 5000read_timeout_ms = 30000Global middleware
Middleware that applies to all routes goes in a top-level [middleware] section (same field structure as [route.middleware]).
[middleware.rate_limit]max_requests = 500window_secs = 60Full 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 = 15timeout_ms = 3000healthy_threshold = 2unhealthy_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 = 503body = "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 = 200window_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"]