Skip to main content
ANVISoftware Solutions
Lesson 23 of 23Advanced16 min

Production Configuration

By the end of this lesson

Configure HTTPS, forwarded headers, secrets and diagnostics for real deployment.

The defaults that make development pleasant are the wrong defaults for a deployed service. Detailed errors, a permissive local certificate, secrets in a file next to the code, an application that believes every request came from the machine next door — all of it is convenient locally and wrong in production.

ASP.NET Core resolves this with the environment name, read from the ASPNETCORE_ENVIRONMENT variable. It selects which appsettings file layers on top of the base one, and your own code branches on it. Getting that variable right is the first item on the list, because everything else in this lesson depends on it.

Configuration is layered. Later sources override earlier ones: appsettings.json, then appsettings.{Environment}.json, then user secrets in development, then environment variables, then command-line arguments. Knowing the order is how you work out why a setting is not what you expected.

Program.cs — HTTPS, HSTS and forwarded headers
C#
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// Only meaningful when something in front terminates TLS or rewrites the host.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders =
        ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;

    // Defaults trust loopback only. Replace them with your actual proxy,
    // because a header from an untrusted source is caller-supplied data.
    options.KnownProxies.Clear();
    options.KnownNetworks.Clear();
    options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("10.0.0.0"), 16));

    // One hop. Raise it only if you genuinely have chained proxies.
    options.ForwardLimit = 1;
});

builder.Services.AddHsts(options =>
{
    options.MaxAge = TimeSpan.FromDays(365);
    options.IncludeSubDomains = true;
});

WebApplication app = builder.Build();

// First, so everything after it sees the corrected scheme and client address.
app.UseForwardedHeaders();

if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler();
    app.UseHsts();          // production only
}

app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();
  • UseForwardedHeaders is registered first because it corrects the request. Anything that runs before it — logging, rate limiting, HTTPS redirection — sees the uncorrected values.
  • KnownProxies and KnownNetworks are the trust list. The middleware only honours forwarded headers from an address on that list, because these headers travel with the request and anyone can set them. Clearing the defaults and naming your own infrastructure is what makes the corrected values trustworthy.
  • ForwardLimit caps how many entries are processed, which matters because X-Forwarded-For accumulates a value per hop and only the ones added by proxies you trust mean anything.
  • HSTS tells a browser to use HTTPS for this host for the given period, so the first insecure request never happens again. It is deliberately outside the development branch: applied on localhost it pins your browser to HTTPS for every local project, which is a confusing afternoon.
  • UseHttpsRedirection sends an insecure request to the secure address. Keep it even behind a proxy that already terminates TLS — with forwarded headers configured, it correctly does nothing, and it protects you if the proxy configuration changes.

Where configuration and secrets should come from, per environment:

appsettings.json
Non-secret defaults that are the same everywhere: log level shape, feature flags, page sizes. This file is in source control, so nothing sensitive belongs in it.
appsettings.{Environment}.json
Non-secret values that differ per environment: the identity provider address, allowed CORS origins, the service name. Also in source control, and also no secrets.
User secrets
For development only. dotnet user-secrets stores values outside the project folder, so a local connection string or test key is never at risk of being committed. It does not exist in production.
Environment variables
The common way to supply configuration to a container or hosting platform. A double underscore stands in for the colon, so Auth__Audience sets Auth:Audience. Visible to anything that can inspect the process, so adequate for many values and not the strongest place for a signing key.
A managed secret store
Azure Key Vault, AWS Secrets Manager, HashiCorp Vault or your platform's equivalent. Access is controlled by identity, values are versioned and rotatable, and reads are audited. This is where connection strings, signing keys and API credentials belong.
appsettings.Production.json — note what is absent
JSON
{
  "Logging": {
    "LogLevel": {
      "Default": "Warning",
      "HrApi": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore.Database.Command": "Warning"
    }
  },
  "AllowedHosts": "api.internal.example",
  "Auth": {
    "Authority": "https://login.example",
    "Audience": "hr-api"
  },
  "Cors": {
    "AllowedOrigins": [ "https://hr.example" ]
  },
  "RateLimits": {
    "SustainedPerMinute": 60,
    "BurstAllowance": 120
  }
}
  • There is no connection string, no signing key and no API credential here. Every one of those comes from the secret store at startup, so this file is safe in source control.
  • Log levels drop to Warning by default with your own namespace at Information. EF Core's command category is explicitly quietened, because at Information it logs every SQL statement your application runs — useful while developing, expensive and noisy in production.
  • AllowedHosts restricts which Host header values the application will serve, which closes off a class of problems caused by requests arriving with an unexpected host.
  • CORS origins and rate limits live in configuration rather than in code, so staging and production differ without a separate build.
  • Keep this file's keys identical in shape to the development version. A setting that exists in one environment and not the other is a failure that only appears after deployment.
Secrets in development, and the same keys in production
Shell
# Development: stored outside the project folder, never committed
dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:Hr" "Server=localhost;Database=hr_dev;..."
dotnet user-secrets set "Auth:ClientSecret" "local-development-placeholder"

# Confirm what is set, and where it is coming from
dotnet user-secrets list

# Production: the same keys, supplied by the platform as environment
# variables. A double underscore stands in for the colon.
# Set these in your deployment configuration, not in a script you commit.
#   ConnectionStrings__Hr
#   Auth__ClientSecret

# Better for real secrets: read them from a managed store at startup,
# authenticating with the deployment's own managed identity, so no
# credential is stored in the environment at all.
  • user-secrets init adds an identifier to the project file; the values themselves live in your user profile. The project folder gains nothing to commit by accident.
  • The key names are identical across environments. Your code reads Configuration["ConnectionStrings:Hr"] and does not know or care which source supplied it.
  • The placeholder value is deliberately an obvious placeholder. Never write a real secret into documentation, a sample, a test or a comment, even one you believe is unused.
  • Reading from a managed store with a platform identity is the strongest of these options, because there is no long-lived credential sitting in configuration for anyone to find.

Summary

  • The environment name selects which settings layer on and which code branches run, so verify it on the deployed instance
  • Behind a proxy the client address and scheme are wrong until forwarded headers are configured, and the scheme mistake causes a redirect loop
  • Only honour forwarded headers from a trust list you set, because those headers arrive with the request
  • HSTS belongs in production only; enabled locally it pins your browser to HTTPS for every project
  • Secrets come from user secrets in development and a managed store in production, never from a file in source control

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Diagnose the redirect loop

A newly deployed API works when called from inside the cluster and returns an endless redirect from outside. The proxy terminates TLS and forwards requests as http.

Explain the loop step by step, then name the two changes that would each stop it, and say which one you would make.

Show solution

The loop: a user requests the HTTPS address. The proxy terminates TLS and forwards plain http to the application. UseHttpsRedirection sees an insecure request and responds with a redirect to the HTTPS address. The user follows it, the proxy forwards http again, and the application redirects again.

Two changes each stop it. Configure forwarded headers so the application learns the original scheme was https and the redirect no longer triggers. Or remove UseHttpsRedirection, since the proxy is already enforcing HTTPS at the edge.

Configure forwarded headers. Removing the redirection makes the symptom go away while leaving the client address wrong for logging and rate limiting, and it removes a protection that still matters if the proxy configuration ever changes. Fixing the cause fixes several things at once; deleting the redirect fixes one and hides the rest.

Try it yourself

Fail fast on a missing setting

Bind the Auth section to a typed options class with Authority and Audience, both required. Use options validation so the application refuses to start if either is missing.

Then remove Audience from configuration and confirm startup fails with a clear message rather than the API running and rejecting every token.

Show solution

AddOptions with a data-annotated options class, ValidateDataAnnotations and ValidateOnStart gives you a startup failure naming the missing key.

The reason to prefer this over a null check later is when the failure happens. A missing audience with no validation produces an application that starts, reports itself healthy, and rejects every request — which looks like an authentication problem and sends you looking in the wrong place.

Failing at startup also stops a bad deployment from rolling forward, because the instance never becomes ready. That turns a silent misconfiguration into a visible, immediate, reversible failure.

C#
public sealed class AuthOptions
{
    [Required, Url]
    public string Authority { get; set; } = string.Empty;

    [Required]
    public string Audience { get; set; } = string.Empty;
}

builder.Services
    .AddOptions<AuthOptions>()
    .Bind(builder.Configuration.GetSection("Auth"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

Saved in this browser only.

End of the published lessons

That is everything written so far in ASP.NET Core

More lessons in this course are on the way. In the meantime, the course page shows the full roadmap, and the projects are the best way to consolidate what you have covered.