devinside.Marcin Borecki Ideas. Systems. Evidence.
← The notebook

Engineering

An ASP.NET Request That Works Locally and Fails After Deployment

Real TLS, trusted forwarded headers, PathBase and a missing artifact produce different deployment failures; a bounded Nginx/ASP.NET fixture separates them with actual HTTP evidence.

Explore the mechanism ↓
Editorial ink and watercolor illustration for Which boundary changed the request?

START WITH THE MECHANISM

Interactive companion and laboratory update: 12 November 2026. The editorial date places the topic on the timeline; this current explanation and its measured evidence retain their actual preparation and execution dates.

THE FULL INVESTIGATION

An ASP.NET Request That Works Locally and Fails After Deployment

Editorial context: 19 March 2026 · Prepared and updated: 12 November 2026
Dates and technology context

This is a retrospective editorial timeline. Topic context: 19 March 2026. First publicly available: 12 November 2026. Current text, visuals and laboratory revisions were prepared on 12 November 2026.

SDK10.0.400/runtime10.0.15/Nginx1.28.0 containers, digest identities, five configurations and spoofing checks are12Nov2026 evidence. Earlier examples require period-correct syntax/defaults.

The application works when a developer calls it directly. Behind a proxy, the same endpoint redirects forever, returns a route-not-found response or cannot locate a file that was present on the development machine. Each symptom can tempt the team into changing several settings until the screen starts working.

That approach can remove the symptom while leaving the explanation wrong. A forwarding header might be accepted from too many senders. A path rewrite might repair one route while breaking generated links. A copied file might exist until the next container restart. The immediate result looks better, but the deployment contract is still implicit.

This article follows one request through an actual local Nginx and ASP.NET Core fixture. It deliberately creates five configurations, records their HTTP behavior and then verifies the corrected path. The comparison is functional, not a performance benchmark. It contains no public deployment, cloud account or production traffic.

The application was built with SDK 10.0.400 and executed in an ASP.NET runtime 10.0.15 container. Nginx 1.28.0 terminates real local TLS and forwards HTTP to the application. Both images are addressed by the exact registry digest recorded in the recipe, rather than by a floating tag during execution. Every published port is explicitly bound to 127.0.0.1.

The fixture passed its five HTTP configurations and the additional spoofing checks. The accompanying recipe also passed missing-environment, occupied-port and partial-provisioning cleanup scenarios. These results establish a reproducible local request path. They do not certify a production proxy configuration or imply that every hosting failure has the same cause.

Begin with the request the user actually made

01 / See the idea

One request crosses two scheme boundaries

One request crosses two scheme boundaries. The client uses real HTTPS to Nginx, while Nginx forwards HTTP inside the container network. The application needs an accepted forwarding claim to reconstruct the original scheme.

The client uses real HTTPS to Nginx, while Nginx forwards HTTP inside the container network. The application needs an accepted forwarding claim to reconstruct the original scheme.

Interpretation and evidence
  • Separate external and upstream connections.
  • Forwarded data needs a trusted sender.
  • Inspect the context before choosing a repair.
executed local fixture; evidence/check-results.json; conceptual sequences labeled

The external request is https://127.0.0.1:<port>/shop/inspect. Its scheme is HTTPS, its authority includes the published port, and its public base path is /shop. Nginx receives that request over TLS, then sends an HTTP request to the application on its private container port 8080.

The application has a route named /inspect. It therefore needs two pieces of reconstructed context before routing and link generation make sense: the original HTTPS scheme and the fact that /shop is the application's external base path. The incoming TCP connection alone cannot supply both.

This is not an exotic deployment. TLS termination changes which connection is encrypted. Mounting an application below a path changes which part of the URL belongs to hosting and which part belongs to the application's own route. A direct local call often hides both transformations because the developer uses a different URL and has no intermediate server.

The diagnostic endpoint returns a deliberately small observation: scheme, host, path base, remaining path, generated link, remote peer before and after forwarding, runtime version and fixture mode. It does not dump every header, configuration value or environment variable. The result is enough to discriminate between the hypotheses in this experiment without turning diagnostics into an unnecessary data export.

A useful investigation starts with those expected values. If the response says HTTP after a valid external HTTPS request, investigate the scheme boundary. If the remaining route still contains /shop, investigate path-base processing. If routing succeeds but a separate artifact endpoint fails, investigate the artifact contract rather than changing proxy headers again.

This is a small measurement contract in the ordinary sense of the word: define the observation before interpreting it. A status code is evidence, but its meaning becomes much stronger when attached to the request and boundary that produced it.

Forwarded headers are claims with a trusted sender

Nginx knows that its client connection used TLS. The application sees a plain HTTP connection from Nginx. The proxy communicates the original scheme using X-Forwarded-Proto, and the application decides whether that claim came from an accepted proxy.

The fixture enables only the forwarding fields it needs and adds the actual allocated proxy address to the trusted list:

builder.Services.Configure<ForwardedHeadersOptions>(o =>
{
    o.ForwardedHeaders =
        ForwardedHeaders.XForwardedFor |
        ForwardedHeaders.XForwardedProto;
    o.ForwardLimit = 1;
    o.KnownProxies.Add(trusted);
});

The trusted value is obtained by inspecting the newly started proxy container on this run's dedicated network. It is not copied from a previous container instance or accepted from the incoming request. The framework's loopback defaults are retained; the recipe does not clear the trust lists to accept every sender.

Microsoft documents that forwarded context can affect redirects and application behavior, and that proxy topology must inform the trust configuration. The security boundary is the identity of the immediate forwarding peer, not the persuasiveness of the header text. ASP.NET Core proxy and load-balancer configuration.

The test has a wrong-trust mode that supplies an unrelated documentation address instead of the actual proxy address. The header still exists on the request. Its value is still the string https. Yet the application does not accept it as an authoritative statement about the original connection.

This distinction explains a class of failures that looks confusing after a servicing update. Older behavior or incomplete configuration may have allowed a deployment to appear functional without a correctly specified proxy boundary. Microsoft's compatibility note describes hardening that ignores forwarding claims from unknown proxies and warns that an unconfigured legitimate proxy can produce redirect problems. The right response is to describe the topology accurately, not disable the trust check globally. Forwarded-header trust change.

The reference does not implement authentication. Knowing the original scheme or client address is not the same as knowing the user's identity. These observations support hosting behavior; they must not become an improvised authorization scheme simply because they are convenient to read.

A redirect loop can be a truthful response to the wrong context

02 / Watch the mechanism

A self-redirect repeats the same misunderstanding

A self-redirect repeats the same misunderstanding. Both the no-forwarding and wrong-trust configurations returned307 to the original HTTPS URL. Repeating the request produced the same destination; the test did not follow an unbounded loop.
Enlarge diagram

Static view. Play to see how the mechanism unfolds.

Both the no-forwarding and wrong-trust configurations returned307 to the original HTTPS URL. Repeating the request produced the same destination; the test did not follow an unbounded loop.

Interpretation and evidence
  • The client was already using HTTPS.
  • The application still saw HTTP.
  • Process trusted headers before redirection.
executed local fixture; evidence/check-results.json; conceptual sequences labeled

The application uses HTTPS redirection. In the no-forwarding configuration, that middleware sees the proxy-to-application HTTP connection and concludes that the request should be redirected to HTTPS. The generated destination is the same public HTTPS URL the client just requested.

The client returns through Nginx, which again forwards HTTP internally. The application again sees HTTP. Repeating the request therefore produces the same redirect destination. Both components are behaving consistently with the information they have; the missing element is the accepted reconstruction of the original scheme.

The fixture does not follow redirects indefinitely to demonstrate this. It makes a request, records the 307 response and its Location, and repeats the same request once. It asserts that both redirects point to the original public URL. That is sufficient evidence of the self-redirect mechanism without creating an unbounded loop.

The wrong-trust mode produces the same observed symptom for a different configuration reason. Forwarded-header middleware runs, but it does not trust the actual proxy. This is why the repair cannot be selected from the status code alone. The configuration and pre-forwarding peer observation distinguish disabled processing from rejected forwarding authority.

In the fixed path, forwarding runs before HTTPS redirection:

app.UseForwardedHeaders();
app.UseHttpsRedirection();
app.UsePathBase("/shop");
app.UseRouting();

The order expresses a dependency. Redirection must evaluate the reconstructed scheme. Routing must evaluate the path after the public base path has been separated. Reversing that order can make a correct option ineffective because the consumer already made its decision.

This is a useful debugging pattern beyond this particular middleware. When configuration appears correct but behavior is wrong, ask when the relevant value is read. A setting that is applied after its consumer cannot retroactively repair the consumer's earlier decision.

Decide which component owns the path prefix

03 / A concrete example

The public prefix belongs somewhere explicit

The public prefix belongs somewhere explicit. Nginx preserves /shop in this fixture. UsePathBase splits it before routing, so the route sees /inspect and generated links retain the public prefix.

Nginx preserves /shop in this fixture. UsePathBase splits it before routing, so the route sees /inspect and generated links retain the public prefix.

Interpretation and evidence
  • Choose preserve or strip deliberately.
  • Run path-base handling before routing.
  • A200 response does not prove links are correct.
executed local fixture; evidence/check-results.json; conceptual sequences labeled

There are two common ways to mount an application below a public prefix. The proxy can remove the prefix before forwarding, or it can preserve it and let the application split it into PathBase and Path. Either design can work. Mixing the assumptions of both designs is the problem.

This fixture deliberately preserves /shop. Its Nginx proxy_pass does not add a replacement URI component. The application then calls UsePathBase("/shop") before explicit routing. The resulting observation is a path base of /shop and a route path of /inspect.

Nginx's proxy module documents that URI handling depends on how proxy_pass is specified. A trailing URI component is therefore not cosmetic configuration punctuation. It can change what reaches the upstream server. Nginx proxy module.

The no-pathbase case keeps forwarded-scheme processing correct but omits the path-base middleware. The response is 404 because the request path still includes the public prefix while the application route does not. This is an intentionally different failure from the redirect cases. Changing HTTPS settings cannot make that route match.

The fixed endpoint also constructs a link using the observed scheme, host and path base. The test checks these fields, not just a successful response code. A route can return 200 while generated links send the user to the wrong root or scheme; that would be an incomplete repair for an application that emits navigation or callback URLs.

The diagnostic link is an application-owned illustration rather than a complete link-generation framework. A production application should use its route and URL-generation facilities consistently and test the actual links its clients consume. The lesson is to include those consumers in the hosting contract rather than treating a single successful endpoint as the whole user experience.

Overwrite client forwarding claims at the proxy boundary

05 / See the idea

Trust the proxy, not a client-supplied claim

Trust the proxy, not a client-supplied claim. A direct HTTP request with a forged HTTPS header still redirected. Through Nginx, forged forwarding values were overwritten and the application observed the legitimate HTTPS context.

A direct HTTP request with a forged HTTPS header still redirected. Through Nginx, forged forwarding values were overwritten and the application observed the legitimate HTTPS context.

Interpretation and evidence
  • Direct spoof remains untrusted.
  • Nginx supplies observed connection data.
  • These tests are not user authentication.
executed local fixture; evidence/check-results.json; conceptual sequences labeled

The proxy does not blindly relay a client's chosen forwarding values. It supplies values derived from the connection it actually received:

proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;

The experiment sends an external request claiming X-Forwarded-Proto: http and a fictional client address. Nginx replaces those claims, and the application still observes HTTPS with a client address different from the injected value. This verifies the selected proxy behavior on the actual HTTP path.

A second negative check bypasses Nginx through a separate loopback-published application port and sends X-Forwarded-Proto: https. The immediate sender is not the trusted proxy container. The application still redirects the HTTP request, demonstrating that the supplied header did not override its transport context.

Those checks are narrowly scoped. They do not test a multi-hop proxy chain, forwarded-host processing, arbitrary address normalization or every deployment's network rules. The fixture enables one forwarded hop and does not enable X-Forwarded-Host. It also preserves the ordinary Host field and does not claim a complete production host-allowlist policy. A real deployment must constrain the authorities it serves and test that policy separately.

This is why a repair that simply trusts all forwarding headers is unsatisfactory. It can make the positive path pass while making the negative path meaningless. The useful acceptance condition includes both: the legitimate proxy can reconstruct the scheme, and an untrusted direct caller cannot do so by typing the same header.

Treat the missing file as another boundary, not another proxy setting

The fixture includes /shop/data, which reads a small synthetic artifact through a configured path. In the missing-file mode, scheme and path-base processing are correct, so /shop/inspect returns 200. The artifact path points to a location that does not exist, and /shop/data returns a controlled 500 problem response.

The fixed mode reads fixture-artifact-v1 from a read-only bind mount. The test verifies the HTTP status and keeps the response body in the evidence record. This makes the artifact's absence distinct from a routing or forwarding failure.

The example does not claim that a bind mount is the correct persistence design for an Azure service. It is an explicit local input used to demonstrate a dependency. A cloud application might receive the artifact in its deployment package, retrieve it from object storage or use a managed configuration service. Each option has a different lifetime and access contract.

The important question is whether the path is meaningful in the runtime environment, not whether it existed beside the developer's project. A working directory, container image layer and host-mounted directory are different namespaces. Copying an absolute development path into configuration is not a portable description of the required data.

This fixture fails visibly instead of manufacturing a default artifact. In production, a missing optional file might justify a fallback. A missing required file should usually block readiness or produce a clear operational failure. The product contract determines which it is; convenience during deployment should not silently decide it.

Make the environment inspectable without exposing secrets

The recipe records image digests, the application runtime reported by the endpoint, container CPU and memory limits, loopback port bindings, source and published-assembly hashes, and the observed HTTP results. It does not print certificate private keys or scan unrelated machine configuration.

Each active container is limited to one CPU and 512 MiB. At most the proxy and one application container are active together. Five application configurations are run sequentially against the same proxy path, then removed by their exact container IDs. The network name and ownership label are unique to the invocation.

The front-end certificate is generated for localhost and 127.0.0.1 with a one-day validity period. The Python client trusts that certificate only in its own SSL context. No certificate is installed into the user's global trust store, and certificate validation is not bypassed with an accept-any callback.

Docker's documentation distinguishes publishing a port on all interfaces from publishing it explicitly on loopback. The recipe uses the latter and verifies the recorded binding after each container starts. The observed engine version is also retained because networking behavior is part of the fixture environment. Docker port publishing.

The final network is a dedicated ordinary bridge, not an egress-isolated network. An initial internal-network configuration on this host left the loopback-published endpoint unreachable. That failed attempt was cleaned up and preserved as diagnostic evidence. The successful recipe does not rebrand its ordinary bridge as a stronger isolation guarantee; its public exposure boundary is the explicit loopback binding and its finite local scope.

Read the observed matrix before choosing a repair

04 / See the idea

Expected failures identify different boundaries

Expected failures identify different boundaries. This matrix comes from the accepted HTTP evidence. A missing artifact fails after routing succeeds, while a missing path base fails at route matching.

This matrix comes from the accepted HTTP evidence. A missing artifact fails after routing succeeds, while a missing path base fails at route matching.

Interpretation and evidence
  • Keep failure categories distinct.
  • Read status with its configuration.
  • Check the artifact separately.
executed local fixture; evidence/check-results.json; conceptual sequences labeled

The accepted run produced the following outcomes. The two 307 cases were checked for the same self-redirect destination on a repeated request. The artifact status belongs to a separate endpoint and is shown only when routing was expected to work.

Configuration Inspect status Artifact status Interpretation
No forwarded-header processing 307 Not evaluated Scheme remains HTTP behind TLS termination
Wrong trusted proxy 307 Not evaluated Forwarding claim exists but is not accepted
No path-base handling 404 Not evaluated Public prefix remains in the route path
Missing required artifact 200 500 Request context is correct; data dependency is absent
Fixed 200 200 HTTPS, /shop base path and artifact match the contract

The successful configuration also passed the direct-spoof and proxy-overwrite checks. The response reported runtime 10.0.15. The actual Nginx executable reported version 1.28.0, and the source and image identities are recorded alongside the results.

This is a functional experiment. It contains no response-time percentile, throughput claim or inference about production capacity. CPU and memory limits contain the fixture's resource use; they are not evidence that a production service will meet a particular workload under those limits.

The initial failures in the recipe are not hidden either. Manual static container addresses were rejected on an automatically configured Docker subnet, so the implementation switched to discovering the actual proxy address. Host connectivity then exposed the internal-network issue described above. Neither failure was an ASP.NET routing result, and neither was counted as one of the five intended HTTP cases.

That separation helps a reader reproduce the work. Infrastructure setup failure, application readiness failure and expected behavioral failure are different evidence categories. A test harness that reports all of them as “negative cases passed” would tell a comforting but inaccurate story.

Distinguish the connection peer from the reported client

The fixed response includes both peerBefore and peerAfter. Before forwarded-header processing, the application sees the proxy container as the immediate network peer. After processing the accepted forwarding information, the reported remote address represents the client as Nginx observed it in this Docker topology.

In the recorded run those were different private addresses. That difference is expected. It demonstrates that forwarding changed the application's interpretation of client context; it does not prove the public identity or geographic location of a person. Docker port mapping and the local network can introduce another boundary between the host process and the address visible to the proxy.

The spoofing assertion therefore avoids demanding an arbitrary literal client address. It requires that the injected fictional address not become the application's accepted client address, while the scheme remains HTTPS. This tests the property that matters without tying the test to a private subnet that Docker may allocate differently on another run.

The trusted proxy address is different: it must match the peer that actually connects to the application. The recipe obtains that address from the created proxy container and passes it to the application. Trusting an entire broad private range would weaken the test because a direct caller on that range might then be allowed to assert the original scheme.

Framework loopback defaults remain in the options. In this container-to-container arrangement, the negative direct-host request arrives through the Docker network path and is not accepted as the configured proxy. That observation belongs to this topology. A service directly exposed on host loopback has a different immediate-peer story and should not copy the expected private addresses from this report.

Do not hide the repair inside the test client

A test client can accidentally make a broken service look healthy. Automatic redirect following can conceal the first incorrect response. An accept-any certificate callback can turn a certificate failure into a silent success. Rewriting a URL before sending it can mask the missing public prefix.

The client in this fixture makes each request explicitly and records the status without following redirects. For the self-redirect cases it sends the same request a second time and compares the returned destination. It does not repair the path or scheme on behalf of the server.

TLS verification also remains active. The generated certificate is trusted through the client's temporary SSL context and must match the loopback endpoint. This is useful because the experiment is about HTTP semantics after real TLS termination. Simply setting a header named https on a plain HTTP request would not demonstrate the complete client-to-proxy connection used here.

The fixed response is checked for individual fields, not only for a JSON body that happens to parse. scheme must be https, pathBase must be /shop, path must be /inspect, and the runtime must match the declared pin. The artifact response has its own status expectation. These checks keep a different endpoint or stale container response from satisfying the intended acceptance accidentally.

This approach also makes failure interpretation more useful. A TLS handshake failure belongs before the application observation. A proxy 502 means the upstream is not yet reachable. A 404 after readiness points to routing under the declared configuration. A 500 from the artifact endpoint means the route ran and reported its missing dependency. The test should preserve these stages rather than flatten all unsuccessful requests into one bucket.

Keep service health and diagnostic exposure separate

The /inspect endpoint is deliberately a laboratory instrument. It reveals request-context fields and fixture mode so the experiment can explain its result. A production endpoint should not necessarily expose the same information to every caller, and it should not dump internal configuration merely because a debugging example did so.

A real health check should answer the operational question assigned to it. Liveness may indicate that the process can serve requests. Readiness may require the artifact or downstream dependency needed for useful work. A diagnostic endpoint can help explain why readiness failed, but it is not automatically suitable as a public status page.

This distinction prevents another false repair: changing readiness to ignore a missing file can make deployment status green while leaving the user's task broken. The fixture preserves the artifact failure as its own result precisely so that a team can decide whether the file is required, optional or incorrectly addressed.

The same principle applies to the corrected proxy path. A successful /inspect response is a meaningful boundary check, not a guarantee that every route, authentication callback, static asset or WebSocket interaction works under the prefix. Those consumers should be added when they belong to the actual application contract. They are not silently included in this small experiment's passing status.

Repair the smallest boundary that explains the symptom

The resulting repair is not a collection of magic settings. It is an ordered contract: Nginx observes the external connection, overwrites its forwarding claims, preserves the public path, and sends the request from a known peer. The application reconstructs trusted context before redirection, separates the path base before routing, and reads an explicitly supplied artifact.

If a future deployment strips the prefix instead, the path-base arrangement must change with it. If another proxy hop is inserted, the trust and forwarding limit must be reconsidered. If the artifact moves into a different storage service, its readiness and access checks must change. A passing result belongs to its topology and inputs, not just to the application commit.

The code is intentionally small enough to adapt to a real incident without copying the whole environment. Start by preserving the failing request and the context the application sees. Choose an observation that separates the leading hypotheses. Change one coherent boundary, then verify both the intended path and a meaningful negative case.

That method is more valuable than memorizing that a particular middleware belongs above another. It lets the team explain why the order matters, which sender is trusted, who owns the prefix and what must exist before a response is useful. When the same application behaves differently after deployment, those are the facts that turn a configuration guess into an engineering diagnosis.

Source code and execution evidence

Download the complete lab, source record and raw execution results. This package supports the specific execution claims in this article. It is not a production certification.

THE AUTHOR

Marcin Borecki

Developer, entrepreneur and business angel. Writing about AI, software, data and the evidence behind technical decisions.

About the author →