Skip to content

.NET Core · Performance & Deployment

Deploying ASP.NET Core to Docker, Kubernetes, and Azure

7 min read Updated 2026-06-23 Share:

Practice Deployment interview questions

Why deployment knowledge matters in .NET interviews

Senior .NET interviews regularly include infrastructure and operational questions: "How would you containerise this?" "What's the difference between liveness and readiness probes?" "How do you manage secrets in production?" Deployment knowledge distinguishes engineers who can take code all the way to production from those who hand it off at a PR. This article covers the full deployment surface.

dotnet publish: choosing the right output mode

dotnet publish has three deployment models, each trading binary size for portability:

# Framework-dependent (default) — requires .NET runtime on host; small output:
dotnet publish -c Release
# Output: ~500 KB of app DLLs

# Self-contained — includes the runtime; works on bare hosts:
dotnet publish -c Release --self-contained -r linux-x64
# Output: ~70 MB

# Single-file — everything in one executable:
dotnet publish -c Release --self-contained -r linux-x64 -p:PublishSingleFile=true
# Output: one ~70 MB file

# ReadyToRun — ahead-of-time compilation for faster startup:
dotnet publish -c Release -r linux-x64 -p:PublishReadyToRun=true

For containers, self-contained is often unnecessary because the runtime is already in the base image (mcr.microsoft.com/dotnet/aspnet). Framework-dependent in a container gives a small, reproducible image.

Kestrel and reverse proxies

Kestrel is ASP.NET Core's built-in HTTP server. It handles raw TCP, TLS, and HTTP/2 and is fast enough for production. In most deployments it sits behind a reverse proxy (Nginx, Apache, IIS) that handles TLS termination, static files, and connection management.

// When behind a reverse proxy, trust forwarded headers:
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
    ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
});
// Call before UseAuthentication and UseRouting

// Without this, Request.Scheme returns "http" even for HTTPS requests.
// Cookie Secure flags and redirect URLs will be wrong.

Kestrel limits should also be tuned in production:

builder.WebHost.ConfigureKestrel(opts =>
{
    opts.Limits.MaxConcurrentConnections = 1000;
    opts.Limits.MaxRequestBodySize       = 10 * 1024 * 1024;
    opts.Limits.RequestHeadersTimeout    = TimeSpan.FromSeconds(30);
});

Multi-stage Dockerfile

A multi-stage build uses the SDK image to compile and the smaller runtime image to run. The final image contains no build tools, reducing the attack surface and image size.

# Stage 1: build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src

# Copy project file first — Docker caches the restore layer until .csproj changes:
COPY ["MyApp/MyApp.csproj", "MyApp/"]
RUN dotnet restore "MyApp/MyApp.csproj"

COPY . .
WORKDIR /src/MyApp
RUN dotnet publish "MyApp.csproj" -c Release -o /app/publish --no-restore

# Stage 2: runtime
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app

# Run as non-root user:
RUN useradd --uid 1001 --no-create-home appuser
USER appuser

COPY --from=build /app/publish .

ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://+:8080

EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApp.dll"]

SDK image: ~750 MB. Runtime image: ~220 MB. Copying only the publish output keeps the final image small and free of source code.

The COPY ["MyApp.csproj"] / dotnet restore / COPY . . sequence is critical for layer caching — the restore layer only rebuilds when the project file changes, not on every source change.

Environment configuration

ASP.NET Core layers configuration in priority order (later sources override earlier):

  1. appsettings.json — base config, committed to source control
  2. appsettings.{Environment}.json — environment-specific overrides
  3. Environment variables — injected by OS, Docker, or Kubernetes
  4. Command-line arguments — highest priority
# Environment variables use __ (double underscore) as the colon separator:
ConnectionStrings__Default=Server=prod-db;Database=app;

# Equivalent JSON path: ConnectionStrings.Default

Never commit secrets to source control. The mapping:

EnvironmentSecret source
Developmentdotnet user-secrets
Docker-e flag or Docker secrets
KubernetesKubernetes Secrets via env vars
AzureAzure Key Vault via Managed Identity
// Azure Key Vault — no credentials needed with Managed Identity:
builder.Configuration.AddAzureKeyVault(
    new Uri($"https://{vaultName}.vault.azure.net/"),
    new DefaultAzureCredential());

Kubernetes: liveness and readiness probes

These two probe types serve entirely different purposes and must be wired to separate endpoints.

livenessProbe:
  httpGet:
    path: /health/live    # no dependency checks — just "is the process alive?"
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 10
  failureThreshold: 3     # restart pod after 3 consecutive failures

readinessProbe:
  httpGet:
    path: /health/ready   # checks database, Redis, etc.
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 5
  failureThreshold: 2     # remove from load balancer after 2 failures
// /health/live — always 200 if the process is running:
app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false });

// /health/ready — checks external dependencies:
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = c => c.Tags.Contains("readiness"),
});

A database outage should make pods unready (stop receiving traffic), not unlive (restart). Restarting a pod can't fix a database problem.

Graceful shutdown

Kubernetes sends SIGTERM before forcibly killing a pod. ASP.NET Core handles this automatically — it stops accepting new connections and waits for in-flight requests to complete. The default timeout is 5 seconds; increase it for long requests.

builder.Host.ConfigureHostOptions(opts =>
    opts.ShutdownTimeout = TimeSpan.FromSeconds(30));

// Background services must respect the cancellation token:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        var job = await _queue.DequeueAsync(stoppingToken);
        await ProcessAsync(job, stoppingToken);
    }
}

A preStop lifecycle hook in Kubernetes adds a sleep before SIGTERM, giving the load balancer time to drain connections before the process starts shutting down:

lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 5"]

GitHub Actions CI/CD

name: Build and Deploy
on:
  push:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-dotnet@v4
      with: { dotnet-version: '8.0.x' }
    - run: dotnet restore
    - run: dotnet build --no-restore -c Release
    - run: dotnet test --no-build -c Release --collect:"XPlat Code Coverage"
    - run: dotnet publish src/MyApp -c Release -o ./publish
    - uses: actions/upload-artifact@v4
      with: { name: publish, path: ./publish }

  deploy:
    needs: build-and-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment: production          # requires manual approval if configured
    steps:
    - uses: actions/download-artifact@v4
      with: { name: publish, path: ./publish }
    - uses: azure/webapps-deploy@v3
      with:
        app-name: myapp-prod
        publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
        package: ./publish

Separating build-and-test from deploy ensures tests pass before deployment runs. The environment: production setting enables manual approval gates in GitHub.

Azure App Service: deployment slots

Azure App Service deployment slots enable zero-downtime releases:

# Create a staging slot:
az webapp deployment slot create --name myapp-prod --slot staging \
    --resource-group myapp-rg

# Deploy to staging (app is not yet live):
az webapp deployment source config-zip --name myapp-prod --slot staging \
    --resource-group myapp-rg --src ./publish.zip

# Warm up the staging slot (health probes, cache population):
curl https://myapp-prod-staging.azurewebsites.net/health/ready

# Swap staging → production (atomic, instant):
az webapp deployment slot swap --name myapp-prod --slot staging \
    --resource-group myapp-rg

If the swap introduces a regression, swap back in seconds.

Production deployment checklist

The most common reasons a first production deployment fails:

// 1. Environment name is Production (not Development):
// ASPNETCORE_ENVIRONMENT=Production
// — disables developer exception pages, enables HSTS

// 2. Secrets are NOT in committed files:
// Use env vars, Azure Key Vault, or Kubernetes Secrets

// 3. HTTPS enforced:
app.UseHttpsRedirection();
app.UseHsts();

// 4. Security headers:
ctx.Response.Headers.Append("X-Content-Type-Options", "nosniff");
ctx.Response.Headers.Append("X-Frame-Options", "DENY");

// 5. Data protection keys are shared between instances:
builder.Services.AddDataProtection()
    .PersistKeysToAzureBlobStorage(blobClient)
    .ProtectKeysWithAzureKeyVault(keyIdentifier, credential);
// Without this, cookies set by one instance cannot be read by another

// 6. Database migrations ran BEFORE startup:
// dotnet ef database update (in CI, before deploy step)

// 7. Required configuration validated at startup:
builder.Services.AddOptions<DatabaseOptions>()
    .BindConfiguration("Database")
    .ValidateDataAnnotations()
    .ValidateOnStart(); // startup crash > runtime NullReferenceException

Data protection key sharing is the single most common first-production surprise for .NET developers — without it, cookie authentication silently breaks on multi-instance deployments.

Rule of thumb: Check the environment name, secret injection, HTTPS enforcement, data protection key sharing, and migration status before every first production deployment. A startup crash with a clear configuration error message is always preferable to a runtime failure discovered under load.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel