Production Deployment & Hosting
Kestrel behind Nginx/IIS, environment-based configuration, and health check endpoints.
Kestrel: the built-in web server
Every ASP.NET Core application ships with Kestrel, a fast, cross-platform web server built directly into the framework — there's no separate web server to install just to run dotnet run in development. Kestrel is genuinely capable of serving internet traffic directly, including HTTPS, but the conventional production setup still puts a dedicated reverse proxy in front of it (Nginx on Linux, IIS on Windows), for reasons that matter more at scale than they do for a single small app: centralized TLS certificate management, serving multiple applications behind one shared port 80/443, request buffering and connection hardening the proxy has already solved, and an extra layer of defense between the public internet and the application process itself.
Nginx as a reverse proxy
A typical Nginx configuration forwards traffic to Kestrel listening on a local port:
server {
listen 80;
server_name api.noalabs.example.com;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection keep-alive;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
By default, ASP.NET Core sees every request as coming from the proxy's own local IP over plain HTTP — it has no idea a client connected over HTTPS to a different public address unless it's told to trust the X-Forwarded-* headers Nginx adds above:
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
});
Without this middleware, things that depend on knowing the real client (IP-based logging, Request.IsHttps checks, HTTPS redirection) silently see the proxy's connection details instead of the actual client's.
IIS on Windows
On Windows, the equivalent role is played by the ASP.NET Core Module (ANCM), an IIS extension that either proxies requests to a separately-running Kestrel process (out-of-process hosting) or hosts the app directly inside IIS's own worker process, w3wp.exe (in-process hosting — the default and generally faster option for new projects, since it skips the extra network hop to a separate Kestrel process).
Environment-based configuration, recap
The middleware-and-configuration page in this track covers appsettings.{Environment}.json overlaying the base appsettings.json. In a real deployment, the environment and listening address are set from outside the application, typically as environment variables set by whatever's launching the process (a systemd unit, a container, an IIS app pool):
export ASPNETCORE_ENVIRONMENT=Production
export ASPNETCORE_URLS="http://0.0.0.0:5000"
dotnet MyApi.dll
ASPNETCORE_ENVIRONMENT drives which appsettings.*.json overlay loads and which branches of if (app.Environment.IsDevelopment())-style code run; ASPNETCORE_URLS tells Kestrel which address(es) and port(s) to actually bind.
Health check endpoints
ASP.NET Core has a built-in health checks framework, exposed as a plain HTTP endpoint that load balancers, container orchestrators, and uptime monitors can poll:
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy())
.AddDbContextCheck<AppDbContext>();
var app = builder.Build();
app.MapHealthChecks("/health");
AddDbContextCheck<T> verifies the registered DbContext can actually reach the database, not just that the process is running. In an orchestrated environment like Kubernetes, it's worth distinguishing a liveness check (is the process alive at all — restart the container if not) from a readiness check (is it ready to receive traffic right now — pull it out of the load balancer's rotation if not, without necessarily restarting it): a database being briefly unreachable is a transient readiness problem, not a reason to kill and restart an otherwise-healthy process.
Common mistakes
- Exposing Kestrel directly to the public internet without a reverse proxy (or without hardening the equivalent settings Kestrel itself now supports) — it works, but skips the connection hardening, buffering, and centralized TLS management a dedicated proxy provides almost for free.
- Forgetting
UseForwardedHeadersbehind a reverse proxy — the app then sees the proxy's own IP and scheme instead of the real client's, which can silently break HTTPS redirection (an infinite redirect loop is a classic symptom) and IP-based logging/rate limiting. - Hardcoding environment-specific values (connection strings, feature flags) into
appsettings.jsoninstead of relying onASPNETCORE_ENVIRONMENTplus a layeredappsettings.{Environment}.jsonor environment variables. - Wiring a single health check endpoint into both a Kubernetes liveness and readiness probe — a check that includes a database ping belongs on readiness; using it for liveness too means a brief database hiccup gets the whole application container killed and restarted instead of just temporarily removed from load-balancer rotation.
Interview questions
Q: Why put a reverse proxy like Nginx in front of Kestrel instead of exposing it directly? Kestrel is capable of serving internet traffic directly, but a reverse proxy centralizes TLS certificate management, lets multiple applications share one public port 80/443, and provides connection hardening and buffering that's already been solved at the proxy layer — worthwhile even though it isn't strictly mandatory the way it once was.
Q: What does UseForwardedHeaders actually fix?
Behind a reverse proxy, ASP.NET Core normally sees every request as arriving from the proxy's local IP over plain HTTP — UseForwardedHeaders reads the X-Forwarded-For/X-Forwarded-Proto headers the proxy adds and restores the real client IP and scheme, which anything depending on them (HTTPS redirection, IP-based logging) otherwise gets wrong.
Q: Why distinguish a liveness check from a readiness check in a health check endpoint? A liveness check answers "is this process healthy enough to keep running" (failure restarts the container); a readiness check answers "is this instance ready to receive traffic right now" (failure just pulls it out of load-balancer rotation temporarily). A database being briefly unreachable is a readiness problem — restarting an otherwise-fine process over it, because the same check backs both probes, causes unnecessary churn.