Every strangler migration needs a front door that the old application does not own. Until that exists, moving a slice means changing DNS, editing IIS bindings, or asking users to visit a different URL, and all three turn a routine cutover into an event. The fix is a small reverse proxy in front of the legacy site, deployed long before anything is rebuilt.
We covered why the router goes first in sequencing a strangler migration on WebForms. This is the build. By the end you will have a YARP host on .NET 8 that sits in front of a WebForms or Classic ASP application on IIS, forwards everything unchanged, and can move one path prefix to a new application with a config edit and a restart.
You need the .NET 8 SDK, an IIS site to point at, and somewhere to host the shim: another IIS site on the same box, a Windows service, or a container. YARP is a Microsoft-maintained library, not a product you install.
Step 1: the pass-through shim
Start with a host that changes nothing. This is the version you run in production for a week.
dotnet new web -o Relic.Shim
cd Relic.Shim
dotnet add package Yarp.ReverseProxy
Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
builder.Services.Configure<ForwardedHeadersOptions>(o =>
{
o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
});
var app = builder.Build();
app.UseForwardedHeaders();
app.MapReverseProxy();
app.Run();
appsettings.json:
{
"ReverseProxy": {
"Routes": {
"legacy-catchall": {
"ClusterId": "legacy",
"Order": 1000,
"Match": { "Path": "{**catch-all}" }
}
},
"Clusters": {
"legacy": {
"Destinations": {
"iis": { "Address": "http://10.0.4.12:8080/" }
}
}
}
}
}
Two details that matter. Order is high on the catch-all so that every slice route you add later wins without reordering the file. And the legacy site keeps its own internal address and port; users only ever reach the shim.
Point the public hostname at the shim, leave the legacy site reachable only from the shim's host, and watch it for a week. If nothing changes, the plumbing is right. You have also spent your change-control budget once, up front, instead of on the day of the first cutover.
Step 2: the things that break in pass-through
They are always the same handful.
Absolute URLs and redirects. A legacy app that builds links from Request.ServerVariables("SERVER_NAME") or issues Response.Redirect with a full URL will send users to the internal hostname. Fix it in the app if you can. If you cannot, rewrite the header at the shim:
"legacy": {
"Destinations": { "iis": { "Address": "http://10.0.4.12:8080/" } },
"HttpRequest": { "Version": "1.1" }
}
with a response transform on the route:
"Transforms": [
{ "ResponseHeaderRemove": "Server" },
{ "RequestHeader": "X-Forwarded-Host", "Set": "orders.example.com" }
]
Rewriting URLs inside the HTML body is possible and we avoid it. It is slow, it breaks on the one page that builds a URL in JavaScript, and it hides the real problem.
HTTPS termination. If the shim terminates TLS and forwards HTTP, legacy code checking Request.IsSecureConnection will decide the user is insecure and may redirect to https://, which comes back through the shim, forever. UseForwardedHeaders plus X-Forwarded-Proto fixes ASP.NET Core code; ASP.NET Framework code needs the header read explicitly or the loop broken at the legacy end.
Client IP. Anything that logs or authorizes by IP now sees the shim. X-Forwarded-For is set for you; someone has to change the legacy code that reads it.
Long-running requests. Report pages that run for four minutes will hit the default proxy timeout. Raise it deliberately, per cluster, rather than globally:
"HttpRequest": { "ActivityTimeout": "00:10:00" }
Uploads and downloads. Check a large file both ways before you call the pass-through proven. Buffering and request-size limits are where this bites.
Step 3: session and login across two applications
This is the part people underestimate. The moment one path goes to a new application, two applications serve one user, and both need to agree on who that user is.
Ranked by how well they work in practice:
- Share the authentication cookie. If the legacy application already uses, or can be moved to, OWIN cookie authentication,
Microsoft.Owin.Security.Interoplets a .NET Framework app and a .NET 8 app read each other's cookie when they share a data-protection key ring on disk. This is the clean answer, and moving a WebForms app from Forms Authentication to OWIN cookies is usually a day or two. - Terminate authentication at the shim. The shim authenticates against the customer's IdP and forwards a signed header identifying the user; the legacy app trusts it because nothing else can reach it. Good when the legacy login is already being replaced. Requires that the legacy site is genuinely unreachable except through the shim.
- Validate against the legacy app. The new slice calls a small endpoint on the legacy app with the incoming cookie and gets back the user. One extra hop per request, no changes to the legacy login. Ugly and it works.
Session state is a separate question from login, and the answer is usually not "share the session". Most slices read two or three session keys. Pass those explicitly and leave the rest of the grab-bag where it is.
If the legacy application uses in-process session state, add session affinity now, before you have two destinations to worry about:
"legacy": {
"SessionAffinity": {
"Enabled": true,
"Policy": "Cookie",
"AffinityKeyName": ".Relic.Affinity",
"FailurePolicy": "Redistribute"
},
"Destinations": { "iis": { "Address": "http://10.0.4.12:8080/" } }
}
Step 4: move the first slice
A cutover is now a route, above the catch-all:
"Routes": {
"customers-new": {
"ClusterId": "slice-customers",
"Order": 10,
"Match": { "Path": "/customers/{**rest}" }
},
"legacy-catchall": {
"ClusterId": "legacy",
"Order": 1000,
"Match": { "Path": "{**catch-all}" }
}
},
"Clusters": {
"slice-customers": {
"HealthCheck": {
"Active": { "Enabled": true, "Path": "/health", "Interval": "00:00:10" }
},
"Destinations": { "new": { "Address": "https://customers.internal:5001/" } }
}
}
Two habits worth keeping. Move a path prefix, not individual pages: a route list with forty entries is a system nobody can reason about at 6pm. And send a slice's traffic to the new destination only after the characterization harness passes against it; see golden-master tests for legacy web apps for the harness.
For a cautious first cutover, route a named group before everyone by matching a header your login sets:
"customers-new": {
"ClusterId": "slice-customers",
"Order": 10,
"Match": {
"Path": "/customers/{**rest}",
"Headers": [
{ "Name": "X-Pilot-Group", "Values": [ "customers-pilot" ], "Mode": "ExactHeader" }
]
}
}
Step 5: rehearse the rollback
Rollback is deleting the slice route and restarting the shim, and it should take under a minute. Time it on a Tuesday morning with users on the system, before you need it. What you are testing is not the config edit; it is whether anything written by the new slice in those ten minutes is readable by the old code. That is the question the rehearsal answers, and it is cheaper to answer it deliberately than at 2am.
Keep the shim boring. It routes, it forwards headers, it checks health. Every piece of business logic that creeps into it becomes a third application nobody planned to maintain.