Skip to content

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 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.

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 = 10

Backend URL format summary

FormatProtocolDefault port
host:portPlain TCP / HTTP— (required)
http://host:portPlain TCP / HTTP80
https://host:portTLS / HTTPS443

[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:

PatternMatches
/api/*Any path starting with /api/ (prefix match)
/api/pingOnly /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 = 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)

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.

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"] # denylist

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"

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 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.

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

Global 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 = 500
window_secs = 60

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"]