Beyond localhost: Multi-Instance ASP.NET Core Deployment with .NET 10

Khalid Abuhakmeh |

Everything works great on your machine. One instance, one process, one set of keys in memory. Then you scale to two instances, maybe a Kubernetes deployment rolling out replicas, maybe an IIS web farm, and suddenly users are getting logged out mid-session, anti-forgery tokens stop validating, and cached data is inconsistent depending on which server answers the request. Help!

It's almost always the same handful of things. You built your app for a single process, and when you run it in multiple processes, those assumptions break silently.

This post walks through the practices that keep ASP.NET Core apps well-behaved across multiple instances.

If you're running an identity provider like Duende IdentityServer, these concerns are doubly important because auth cookies, token signing, and session state all depend on the infrastructure being set up correctly. The first section covers the universal concerns, the things you need to address regardless of where you deploy. Then we go platform-specific: Kubernetes containers and Windows IIS web farms each have their own set of gotchas.

graph LR
    UT[User<br>Traffic] --> LB[(Load Balancer)]
    LB[Load<br> Balancer] --> A[instance #1]
    LB --> B[instance #2]
    LB --> C[instance N...]
    A --> Redis[(Redis\nData Protection Keys\nDistributed Cache)]
    B --> Redis
    C --> Redis
    A --> DB[(Database)]
    B --> DB
    C --> DB

The diagram above is the target state. Instances are stateless and interchangeable. Shared state lives in shared infrastructure, not in process memory.

Let's get there.

The Universal Checklist

These considerations apply whether you're running on Kubernetes, IIS, bare metal Linux, or anything else. If you're deploying more than one instance, you need all of these.

Data Protection Key Sharing

This is the one that bites the most people. ASP.NET Core's Data Protection system is used under the hood for auth cookies, anti-forgery tokens, and TempData. If you're running Duende IdentityServer, the list gets longer: Data Protection also secures persisted grants, server-side session data, signing keys at rest, and the state parameter for external OIDC providers. By default, keys are generated in-process and stored in memory (or in the app's content root on disk). Each instance generates its own keys.

Here's what that means in practice: a user logs in, gets a cookie encrypted with Instance #1's keys, their next request lands on Instance #2, and the cookie can't be decrypted. They get silently redirected to the login page. No error, no log entry that makes the cause obvious. Just confused users.

The fix is to point all instances to a shared keystore and give them the same application name. The application name is what Data Protection uses to isolate key rings between apps, and it must be identical across every instance.

From our experience with customers, Redis is the most commonly deployed shared store in production:

// Program.cs
builder.Services.AddDataProtection()
    .SetApplicationName("my-app") // Must be identical on every instance
    .PersistKeysToStackExchangeRedis(
        ConnectionMultiplexer.Connect(builder.Configuration["Redis:ConnectionString"]!),
        "DataProtection-Keys");

One important caveat: Redis is volatile by default. If your Redis instance restarts without persistence enabled, you lose all your Data Protection keys, and every cookie and token encrypted with those keys becomes unreadable. Make sure your Redis deployment is configured with AOF (append-only file) or RDB snapshots, so keys survive a restart. If that feels like too much operational overhead, a database-backed store (EF Core's PersistKeysToDbContext or Azure Blob Storage) may be a more durable choice.

If you're on Azure, you can persist keys to Blob Storage and protect them with Key Vault instead. The Duende deployment guide covers the full range of persistence and protection options, and Microsoft's Data Protection documentation covers the underlying framework in detail. Entity Framework Core is also an option if you'd rather keep keys in your existing database.

One thing worth calling out: Data Protection keys and IdentityServer signing keys are completely separate. Data Protection keys encrypt sensitive data at rest. Signing keys sign tokens like JWTs and id_tokens. Both need to be shared across instances, but they're managed differently. The Duende docs on key management cover automatic signing key rotation, storage, and multi-instance considerations.

Distributed Caching

IDistributedCache is ASP.NET Core's interface for shared cache: one interface, multiple backends. The trap is the AddDistributedMemoryCache() extension method, which ships in the box, and looks like a distributed cache. It is actually per-process in-memory storage! It's fine for single-instance development, but it will give you inconsistent results the moment you run two instances.

For production, reach for Redis:

// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration["Redis:ConnectionString"];
    options.InstanceName = "my-app:"; // Prefix to avoid key collisions
});

Worth knowing: There's also a PostgreSQL-backed IDistributedCache provider available via AddDistributedPostgresCache(). If your stack already runs PostgreSQL and you want to avoid an extra Redis dependency, this is worth a look. This is an additional community package.

Session state builds on IDistributedCache, so once you have Redis or Postgres wired up, distributed sessions work automatically. Just make sure AddSession() is configured with reasonable cookie and timeout settings. If you're using Duende IdentityServer, IDistributedCache is also used to protect external OIDC state, JWT replay detection, and device flow throttling, so getting this right matters even if you're not using ASP.NET session state directly.

Forwarded Headers

When a reverse proxy or load balancer terminates TLS and forwards requests to your app, two things go wrong if you don't configure forwarded headers:

  1. HttpContext.Connection.RemoteIpAddress returns the proxy's IP, not the client's.
  2. HttpContext.Request.Scheme is http even though the original request was https. This breaks OAuth redirect URIs, HSTS, and anything else that cares about the scheme.

The fix is a few lines in Program.cs, but it must come before any other middleware:

// Program.cs (must be first)
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor 
                             | ForwardedHeaders.XForwardedProto;
    // Restrict to known proxy IPs in production. Don't leave this open.
    options.KnownProxies.Add(IPAddress.Parse("10.0.0.1"));
});

app.UseForwardedHeaders();

Resist the temptation to call options.KnownProxies.Clear() and accept all forwarded headers. That opens you up to IP spoofing. Lock it down to the IPs you actually know.

Health Checks

Orchestrators and load balancers need a way to know whether an instance is ready to receive traffic. ASP.NET Core has first-class health check support, so use it.

The key distinction is between liveness and readiness. Liveness means "the process is alive." Readiness means "the process is ready to serve traffic." A pod that just started might be alive but not yet ready: database migrations running, caches warming, startup tasks completing.

// Program.cs
builder.Services.AddHealthChecks()
    .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
    .AddCheck<DatabaseHealthCheck>("database", tags: ["ready"])
    .AddCheck<RedisHealthCheck>("redis", tags: ["ready"]);

// Liveness: is the process up?
app.MapHealthChecks("/healthz/live", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("live")
});

// Readiness: are dependencies reachable?
app.MapHealthChecks("/healthz/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready")
});

Point your load balancer or Kubernetes probes at /healthz/ready. That way, a freshly-started instance won't receive traffic until it's actually ready.

For identity servers, consider going deeper: Duende IdentityServer's deployment docs include health-check examples that probe the discovery and JWKS endpoints directly. A healthy discovery response proves the configuration store is reachable. A healthy JWKS response proves the signing key store is working. Both are critical dependencies that a generic "self" check won't catch.

Graceful Shutdown

When an orchestrator wants to remove an instance (e.g., during a rolling update, scaling down, or a node drain), it sends a shutdown signal. Your app has a window to finish in-flight requests before it gets forcibly killed. The default ShutdownTimeout is 30 seconds, which may not be enough if you have background services draining queues or long-running requests still in progress.

// Program.cs
builder.Services.Configure<HostOptions>(options =>
{
    // Give background services more time to finish
    options.ShutdownTimeout = TimeSpan.FromSeconds(60);
});

And in any BackgroundService you write, always honor the stoppingToken:

// In a BackgroundService subclass:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        await DoWorkAsync(stoppingToken); // Pass the token all the way down
    }
}

A service that ignores the cancellation token will be abandoned at shutdown, potentially leaving work in a half-finished state.

Observability with OpenTelemetry

Running multiple instances means logs, traces, and metrics arrive from several processes simultaneously. Without structured, correlated observability, debugging a production issue becomes an exercise in grepping through logs, hoping you land on the right instance.

.NET has first-class OpenTelemetry support. Wire it up early:

// Program.cs
builder.Services.AddOpenTelemetry()
    .ConfigureResource(r => r.AddService(
        serviceName: "my-app",
        serviceVersion: "1.0.0"))
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddOtlpExporter()) // OTEL_EXPORTER_OTLP_ENDPOINT env var
    .WithMetrics(metrics => metrics
        .AddAspNetCoreInstrumentation()
        .AddRuntimeInstrumentation()
        .AddOtlpExporter())
    .WithLogging(logging => logging
        .AddOtlpExporter()); // .NET 9+: unified logging via OTLP (no separate AddOpenTelemetryLogging() needed)

The serviceName attribute is what ties all the telemetry from your instances together in your observability backend. Every trace, every metric, every log line carries it. Set it to something consistent and meaningful.

If you're running Duende IdentityServer, it emits its own OpenTelemetry traces and metrics out of the box. You get counters for token issuance, client secret validation, introspection, and more. The traces are especially useful for following auth flows across service boundaries in a multi-instance setup.

Avoid Console.WriteLine for diagnostic output. Use ILogger<T> everywhere. It writes structured logs that can be queried and correlated.

Kubernetes

With the universal checklist out of the way, let's look at what's specific to running ASP.NET Core in Kubernetes.

Container Images

.NET 8 made rootless containers the default, and it's a meaningful security improvement. The app user (UID 1654) is built into all mcr.microsoft.com/dotnet/aspnet images, and the default port was migrated from 80 (requires root) to 8080.

Tip for .NET 8+: If you're still mapping port 80 in your Dockerfile or K8s manifests, update to 8080. The environment variable ASPNETCORE_HTTP_PORTS=8080 is set by default in the base images.

For the smallest possible attack surface, consider chiseled images:

# Multi-stage build: SDK for building, chiseled for running
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /publish

FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS runtime
WORKDIR /app
# Copy with correct ownership for the non-root app user
COPY --from=build --chown=app:app /publish .
USER app
EXPOSE 8080
ENTRYPOINT ["./MyApp"]

Chiseled images strip out everything not needed to run the app: no shell, no package manager, no extra utilities. They come in around 50MB vs 191MB for the standard image, and significantly reduce your CVE exposure.

If you don't need a custom Dockerfile, dotnet publish /t:PublishContainer will build and push an OCI-compliant image directly from the SDK with no Dockerfile required.

A few runtime environment variables worth setting in your manifests:

Variable Value Why
DOTNET_HOSTBUILDER__RELOADCONFIGONCHANGE false Disables inotify watchers on config files. Prevents "too many open files" in file-heavy environments.
DOTNET_GCHeapCount Match pod CPU requests Tuning GC heap count to available CPUs prevents over-provisioning threads
ASPNETCORE_ENVIRONMENT Production Ensures correct configuration loading and error handling

Kubernetes Manifests

Here's a deployment manifest that puts the universal checklist and container best practices together:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  replicas: 3
  # Zero-downtime rolling updates
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      # terminationGracePeriodSeconds should be ShutdownTimeout + ~5s buffer
      terminationGracePeriodSeconds: 65
      containers:
      - name: my-app
        image: myregistry/my-app:1.0.0
        ports:
        - containerPort: 8080
        env:
        - name: ASPNETCORE_ENVIRONMENT
          value: Production
        - name: DOTNET_HOSTBUILDER__RELOADCONFIGONCHANGE
          value: "false"
        - name: Redis__ConnectionString
          valueFrom:
            secretKeyRef:
              name: my-app-secrets
              key: redis-connection-string
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        # Security: run as non-root, read-only filesystem
        securityContext:
          runAsNonRoot: true
          runAsUser: 1654
          readOnlyRootFilesystem: true
          allowPrivilegeEscalation: false
          capabilities:
            drop: ["ALL"]
        # Point at the readiness endpoint from Section 1.4
        readinessProbe:
          httpGet:
            path: /healthz/ready
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 10
        livenessProbe:
          httpGet:
            path: /healthz/live
            port: 8080
          initialDelaySeconds: 10
          periodSeconds: 30
        # Temporary writable volume if needed (read-only root filesystem)
        volumeMounts:
        - name: tmp
          mountPath: /tmp
      volumes:
      - name: tmp
        emptyDir: {}

A few things are worth calling out here:

terminationGracePeriodSeconds: 65 is 60 seconds (our ShutdownTimeout) plus 5 seconds of buffer. Kubernetes waits this long before sending SIGKILL. If terminationGracePeriodSeconds is shorter than ShutdownTimeout, the process gets killed before it finishes shutting down.

readOnlyRootFilesystem: true is a strong security posture. The app can't write to its own filesystem. Any paths that need to be writable (like /tmp) need an explicit emptyDir volume.

The SIGTERM → shutdown flow looks like this:

sequenceDiagram
    participant K8s as Kubernetes
    participant App as ASP.NET Core App
    participant BG as BackgroundService

    K8s->>App: SIGTERM
    App->>App: ApplicationStopping fires
    App->>BG: CancellationToken cancelled
    BG->>BG: Finish in-flight work
    BG->>App: StopAsync() completes
    App->>K8s: Process exits (0)
    Note over K8s,App: If process doesn't exit within<br/>terminationGracePeriodSeconds, SIGKILL

Autoscaling

Once you've set resource requests and limits, horizontal autoscaling is straightforward:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: my-app-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-app
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 80

Keep minReplicas at 2 or more if availability matters. A single replica means any pod restart causes downtime. For event-driven scaling (queue depth, message lag, custom metrics), look at KEDA as a drop-in complement to the standard HPA.

IIS

IIS web farms have a different set of concerns. The configuration is XML-heavy rather than YAML-heavy, but the underlying problems are the same: shared state needs shared infrastructure, and deployments need care to avoid downtime.

Hosting Models

ASP.NET Core on IIS can run in two modes. In-process (IISHttpServer) runs the app within the IIS worker process (w3wp.exe) and has been the default since .NET Core 3.0. Out-of-process starts a separate Kestrel process, with IIS acting as a reverse proxy.

In-process is faster because requests don't make an extra hop over a loopback adapter. Use it unless you specifically need process isolation (for example, to run multiple app versions in the same app pool, which isn't supported in-process).

Your web.config should look like this:

<aspNetCore processPath="dotnet"
            arguments=".\MyApp.dll"
            hostingModel="inprocess"
            stdoutLogEnabled="false"
            stdoutLogFile=".\logs\stdout" />

A couple of IIS-specific configuration notes: set the app pool's .NET CLR Version to "No Managed Code" because ASP.NET Core manages its own runtime. And always install the .NET Hosting Bundle after the IIS role is enabled, then run net stop was /y && net start w3svc to let IIS pick up the new module.

Web Farm Data Protection

The same Data Protection problem from the universal checklist applies to IIS web farms. On IIS, a common approach is a UNC file share with certificate-based encryption at rest:

// Program.cs
builder.Services.AddDataProtection()
    .SetApplicationName("my-app") // Same on every server in the farm
    .PersistKeysToFileSystem(new DirectoryInfo(@"\\fileserver\shares\dp-keys"))
    .ProtectKeysWithCertificate(
        X509CertificateLoader.LoadPkcs12FromFile(
            @"C:\certs\dp-cert.pfx", 
            builder.Configuration["DataProtection:CertPassword"]));

The certificate needs its private key installed on every server in the farm, not just the one where you originally created it. A common mistake is exporting the cert without the private key, which gives you a runtime error that can be confusing to diagnose. Export as .pfx (with the private key), import on each server, and grant the app pool identity read access.

If a network share feels fragile, the EF Core provider (PersistKeysToDbContext) is a solid alternative. Your existing database becomes the shared key store. If you're running Duende IdentityServer, the Entity Framework operational store already provides persistence for signing keys, persisted grants, and server-side sessions alongside your configuration data.

Zero-Downtime Deployments

For true zero-downtime on IIS web farms, the right tool is Application Request Routing (ARR). ARR lets you route traffic to one set of servers while you deploy to another, a proper blue-green deployment. Microsoft's ARR and NLB guide walks through the setup.

For in-place deployments where you can tolerate a brief interruption, shadow copy reduces (but doesn't eliminate) downtime by letting IIS copy assemblies without locking the running files:

<aspNetCore processPath="dotnet" arguments=".\MyApp.dll" hostingModel="inprocess">
  <handlerSettings>
    <!-- Shadow copy assemblies to reduce file-lock issues during deploy -->
    <handlerSetting name="enableShadowCopy" value="true" />
    <handlerSetting name="shadowCopyDirectory" value="..\ShadowCopy" />
    <!-- Delay old instance shutdown to let IIS drain connections -->
    <handlerSetting name="shutdownDelay" value="5000" />
  </handlerSettings>
</aspNetCore>

For scheduled maintenance windows, app_offline.htm is the blunt instrument: drop a file with that name into the app root, and IIS serves its contents to all requests while the app is stopped. Remove the file to restart. Web Deploy does this automatically during deployment.

Troubleshooting

IIS has a specific set of error codes for ASP.NET Core failures. These appear in the Windows Application Event Log and in the browser when you hit a problem:

Code Meaning First thing to check
502.5 Process failure: app didn't start Is the .NET Hosting Bundle installed? Run dotnet --list-runtimes.
500.30 In-process startup failure Check Event Viewer → Application log for the specific error
500.32 Bitness mismatch App pool "Enable 32-bit Applications" must match your published target
0x800700c1 Bitness mismatch (native error) Same as above. Check app pool and <Platform> in your csproj.

When you need to see startup errors in detail, enable stdout logging temporarily:

<aspNetCore processPath="dotnet"
            arguments=".\MyApp.dll"
            stdoutLogEnabled="true"
            stdoutLogFile=".\logs\stdout" />

Disable it again immediately after you've collected the logs. Stdout logging writes an unbounded log file. Leave it on in production, and you'll eventually fill the disk.

Wrapping Up

The multi-instance mindset is this: test with N=2 before you test with N=20. Add a second instance to your local environment or staging setup and run through your auth flows, your caching paths, your background jobs. If something breaks with two instances, it will definitely break with ten.

The checklist from this post gives you a solid foundation: shared Data Protection keys, a real distributed cache, forwarded headers configured, health checks for readiness and liveness, graceful shutdown that honors timeouts, and structured observability. Get those right, and you've handled the vast majority of multi-instance failures before they reach production.

As always, thanks for reading and sharing my posts. Cheers!


Thanks for stopping by!

We hope this post helped you on your identity and security journey. If you need a hand with implementation, our docs are always open. For everything else, come hang out with the team and other developers on GitHub.

If you want to get early access to new features and products while collaborating with experts in security and identity standards, join us in our Duende Product Insiders program. And if you prefer your tech content in video form, our YouTube channel is the place to be. Don't forget to like and subscribe!

Questions? Comments? Just want to say hi? Leave a comment below and let's start a conversation.