Reverse Proxy
dotnet add package Shiny.Net.HttpServer.Proxyusing Shiny.Net.HttpServer.Proxy;
app.MapProxy("/api/{*path}", "https://api.example.com");Every method on that route is forwarded to the destination, and the answer is copied back.
The reason this belongs beside an embedded server rather than in a load balancer: the device is often the only thing that can reach what the caller wants.
- A phone bridges one of its own loopback services out through a tunnel.
- A Raspberry Pi fronts a printer or a camera that speaks HTTP with no TLS and no authentication — and the Pi adds both.
- A dev server serves the app and forwards
/apito the real backend, so the browser sees one origin and CORS never enters the picture. - A gateway on the LAN spreads requests over two or three instances of a service and takes one out of rotation the moment it stops answering.
The four shapes below escalate. Each is the previous one with something added, and they are the same code path underneath: the single-destination call builds a cluster of one.
1. One destination
Section titled “1. One destination”The remaining path comes from the route’s catch-all parameter, so /api/{*path} sends
/api/orders/7?full=1 to {destination}/orders/7?full=1. A route with no catch-all forwards to the
destination exactly as written, which is what a one-to-one mapping of a single endpoint wants.
Bodies stream in both directions — nothing is buffered, so a large upload through a proxy route costs the same memory as a small one.
Headers are copied except the hop-by-hop ones (Connection, Keep-Alive, Transfer-Encoding,
Upgrade, TE, Trailer, Proxy-*), which describe a connection that is not the one being made.
X-Forwarded-For, -Proto and -Host describe the original caller — without them the upstream sees
this server and nothing else.
| Status | When |
|---|---|
| 502 | The upstream is unreachable or answered nonsense. The caller’s request was fine; ours was not answered |
| 503 | Every destination in the cluster is out of rotation |
| 504 | The upstream did not answer inside Timeout (100s by default) |
Options
Section titled “Options”app.MapProxy("/printer/{*path}", "http://192.168.1.50", o =>{ o.RewriteHost = false; // pass the caller's Host through to a virtual host o.Timeout = TimeSpan.FromSeconds(10); o.BeforeSend = (request, ctx) => request.Headers.Add("X-Api-Key", key);});| Property | Default | Notes |
|---|---|---|
Client |
a shared one | Redirects off, cookies off, no automatic decompression |
Timeout |
100s | Exceeding it is a 504. Does not apply to an upgraded connection |
AddForwardedHeaders |
true |
X-Forwarded-For / -Proto / -Host. A shorthand for Transforms.ForwardedHeaders |
RewriteHost |
true |
Sends the destination’s host, which is what most upstreams route on |
ForwardUpgrades |
true |
WebSockets and any other HTTP/1.1 upgrade |
RequestVersion |
1.1 |
HTTP version for the outbound call |
RewriteUri |
null |
Replaces the whole “destination + remainder” rule |
BeforeSend |
null |
Last chance to touch the outbound request |
AfterReceive |
null |
First look at the upstream response |
Transforms |
empty | The composable pipeline — see below |
OnError |
null |
Answer a failure in your own shape instead of the default status code |
The default client does not follow redirects and does not decompress: following a redirect on the caller’s behalf hides it from them, and decompressing here only to recompress on the way out spends the device’s battery to change nothing.
For a route that does its own deciding, forward from inside an ordinary handler:
app.MapGet("/thing/{id}", ctx => HttpForwarder.ForwardAsync(ctx, "http://192.168.1.50").AsTask());2. A cluster
Section titled “2. A cluster”app.MapProxy("/api/{*path}", cluster =>{ cluster.AddDestination("a", "https://a.internal"); cluster.AddDestination("b", "https://b.internal");
cluster.LoadBalancing = LoadBalancingPolicy.PowerOfTwoChoices;});| Policy | What it does |
|---|---|
PowerOfTwoChoices |
Default. Samples two destinations at random and takes the less busy one |
RoundRobin |
Each request to the next destination in order |
LeastRequests |
The destination with the fewest requests in flight |
Random |
Uniformly random |
First |
Always the first available destination — active/standby, not balancing |
Power-of-two is the default because it costs two random numbers rather than a scan, and because it avoids the failure mode that makes plain least-requests dangerous: every proxy instance agreeing that the same idle destination is the best one and stampeding it simultaneously.
Set cluster.LoadBalancer to an ILoadBalancingPolicy of your own to replace all of them.
Health
Section titled “Health”Health is tracked in two independent places, exactly as the two checks observe it. Either can take a destination out, and neither overwrites the other’s opinion — an active probe that passes while every real request is failing should not put a broken destination back into service.
cluster.HealthCheck.Passive.FailureThreshold = 3;cluster.HealthCheck.Passive.ReactivationPeriod = TimeSpan.FromSeconds(30);
cluster.HealthCheck.Active.Enabled = true;cluster.HealthCheck.Active.Path = "/health";cluster.HealthCheck.Active.Interval = TimeSpan.FromSeconds(10);Passive watches real traffic: after FailureThreshold consecutive failures the destination is out
for ReactivationPeriod, then gets traffic again and has to survive it. Only failures that say
something about the destination count — a caller that hung up, or an upstream that answered 500, says
nothing about whether that instance is reachable.
Active probes on a timer whether or not traffic is flowing, and is off by default: a probe every few seconds against every destination is not free, and on a battery-powered device it is the kind of not-free that shows up in a graph. When it is on, the probe loop follows the server’s own state — it starts when the server starts and stops when it stops, so a backgrounded app is not holding the radio awake to poll upstreams nothing is using.
When every destination is out, the route answers 503.
Session affinity
Section titled “Session affinity”cluster.SessionAffinity.Mode = SessionAffinityMode.Cookie; // or Headercluster.SessionAffinity.FailurePolicy = AffinityFailurePolicy.Redistribute;The first request picks a destination and the caller is pinned to it — by a cookie for a browser, or
by a header for anything else. When the pinned destination is gone or unhealthy, Redistribute picks
a new one and re-pins; Return503 fails instead, for an upstream where landing on a different
instance is worse than failing.
The value written is a hash of the cluster and destination ids rather than the destination id itself. A destination id is usually a host name, sometimes an internal one, and putting it in a cookie publishes the shape of the backend to anyone who opens dev tools — for no gain, since the proxy is the only thing that ever has to recognise it.
3. Transforms
Section titled “3. Transforms”cluster.Transforms .RemovePathPrefix("/api") .SetQueryValue("tenant", "acme") .SetRequestHeader("X-Api-Key", key) .RemoveResponseHeader("Server") .WithForwardedHeaders(ForwardedHeadersMode.Append);Request transforms run after the default path and query have been worked out and before the request is sent. Response transforms run after the upstream’s headers have been copied onto the response and before the body streams back, which is the last moment anything can be changed.
| Request | Response |
|---|---|
AddPathPrefix · RemovePathPrefix · SetPath |
SetResponseHeader |
SetQueryValue · AppendQueryValue · SetQueryRouteValue · RemoveQueryValue |
AppendResponseHeader |
SetRequestHeader · AppendRequestHeader · CopyRequestHeader · RemoveRequestHeader |
RemoveResponseHeader |
SetRequestHost · WithForwardedHeaders |
AddResponseTransform |
AddRequestTransform |
ForwardedHeadersMode.Set — the default — replaces whatever the caller sent, which is the point: a
caller supplying its own X-Forwarded-For is claiming to be a proxy, and believing it is how an
IP allow-list is walked around. Append is for a deliberate chain of
proxies; Off for an upstream that already sits behind something else that writes them.
A query nothing touched is forwarded byte for byte. Re-encoding an untouched query is not a no-op: an
upstream that signs its own URLs, or one that distinguishes %2F from /, notices the difference.
4. Configuration
Section titled “4. Configuration”builder.AddReverseProxy(configuration.GetSection("ReverseProxy"));{ "ReverseProxy": { "Routes": { "api": { "ClusterId": "backend", "Match": { "Path": "/api/{**rest}", "Hosts": [ "*.example.com" ], "Methods": [ "GET", "POST" ] }, "Order": 0, "Transforms": [ { "PathRemovePrefix": "/api" }, { "RequestHeader": "X-Tenant", "Set": "acme" }, { "ResponseHeaderRemove": "Server" } ] } }, "Clusters": { "backend": { "LoadBalancingPolicy": "LeastRequests", "SessionAffinity": { "Mode": "Cookie" }, "HealthCheck": { "Active": { "Enabled": true, "Interval": 10, "Timeout": 5, "Path": "/health" }, "Passive": { "Enabled": true, "FailureThreshold": 3, "ReactivationPeriod": 30 } }, "HttpRequest": { "ActivityTimeout": 30, "RewriteHost": true }, "Destinations": { "d1": { "Address": "https://a.internal" }, "d2": { "Address": "https://b.internal", "Health": "https://b.internal:9000" } } } } }}A catch-all may be written {**rest} or {*rest} — a configuration file copied over from YARP
should not fail to parse over a star. A duration may be "00:00:10" or the bare number 10, which
is seconds.
Without a container, map one straight onto the server and hold the runtime it hands back:
var proxy = app.MapReverseProxy(configuration.GetSection("ReverseProxy"));Two routes sharing a template are collapsed into one endpoint that tries them in Order and matches
on Hosts and Methods; a request matching none of them is a 404. The router itself matches on
method and path, so without that the two would collide in the table and one would silently win.
Reload
Section titled “Reload”Routes in this server are not frozen when it starts — the table is swapped atomically — so a configuration change is applied to the running server rather than needing a restart.
- Clusters that keep their id keep their
ProxyCluster, so a quarantined destination stays quarantined and a probe loop is not torn down and rebuilt on every save. - Routes mapped in code are left alone. Only routes the configuration put there are replaced.
- A file saved half-written is logged and ignored. Keeping the routes that are already working beats tearing down a functioning proxy over a missing brace.
Subscribe to ReverseProxyRuntime.Reloaded to be told when a new configuration has taken effect.
gRPC and trailers
Section titled “gRPC and trailers”Response trailers from the upstream (HttpResponseMessage.TrailingHeaders) are relayed to the caller.
On HTTP/2 and HTTP/3 they go out as a trailing HEADERS frame, and on HTTP/1.1 as the chunked trailer
section, with the upstream’s Trailer announcement kept. gRPC’s status (grpc-status, grpc-message)
travels there, so a gRPC service can sit behind a proxy route. TE: trailers is forwarded; every other
TE value is dropped as hop-by-hop. gRPC needs HTTP/2 to the upstream:
app.MapProxy("/{*path}", "http://127.0.0.1:5001", o =>{ o.RequestVersion = HttpVersion.Version20; // h2c prior knowledge on http:// o.VersionPolicy = HttpVersionPolicy.RequestVersionExact;});The caller has to reach the proxy over HTTP/2 or HTTP/3 as well. gRPC-Web is the option for HTTP/1.1 clients.
Metrics
Section titled “Metrics”On the server’s meter (HttpServerTelemetry.MeterName), next to the
server’s own instruments:
| Instrument | Kind | Attributes |
|---|---|---|
shiny.proxy.upstream.request.duration |
histogram (s) | server.address (destination host:port), http.request.method, http.response.status_code, or error.type when there was no response |
shiny.proxy.upstream.errors |
counter | error.type (the ProxyError: Request, RequestTimeout, RequestCanceled, ResponseBody, Upgrade, NoAvailableDestination), server.address |
The duration runs from sending the request upstream to its response headers, so a slow download to a slow client does not count against the upstream.
WebSockets
Section titled “WebSockets”An Upgrade request is completed against the upstream and, on a 101, the connection is handed over
outright and bytes are pumped both ways until either side stops. So a
WebSocket through a proxy route works end to end, as does anything else
that upgrades on HTTP/1.1.
app.MapProxy("/ws/{*path}", "http://127.0.0.1:9000"); // forwards the handshake and the socketThe request timeout covers the handshake only — applying it to an upgraded connection would close a
WebSocket on the dot. ForwardUpgrades = false turns the whole thing off and answers the upstream’s
response as an ordinary one.


