dotnet publish supports three deployment models: framework-dependent,
self-contained, and single-file. Each trades off binary size, portability,
and startup time differently.
# Framework-dependent (default) — requires .NET runtime installed on host:
dotnet publish -c Release
# Output: ~500 KB of app DLLs + app.exe shim
# Advantage: small, shares runtime with other apps on the host
# Self-contained — includes the .NET runtime in the output:
dotnet publish -c Release --self-contained true -r linux-x64
# Output: ~70 MB including runtime
# Advantage: no runtime required on host; portable to fresh machines
# Single-file — bundles everything into one executable:
dotnet publish -c Release --self-contained true -r linux-x64 \
-p:PublishSingleFile=true
# Output: one ~70 MB file
# Advantage: simple deployment; one file to copy/move
# ReadyToRun (R2R) — ahead-of-time compile for faster startup:
dotnet publish -c Release -r linux-x64 \
-p:PublishReadyToRun=true
# Trimming — remove unused framework code (careful: reflection-heavy code may break):
dotnet publish -c Release --self-contained true -r linux-x64 \
-p:PublishTrimmed=true
# Output: ~20-40 MB depending on what's used
| Mode | Host requirement | Binary size | Startup |
|---|---|---|---|
| Framework-dependent | .NET runtime | Small | Normal |
| Self-contained | Nothing | ~70 MB | Normal |
| Single-file | Nothing | ~70 MB (one file) | Slightly slower |
| R2R | Nothing | Larger | Faster |
Rule of thumb: Use framework-dependent for servers where you control the runtime version. Use self-contained for containers (Docker handles the runtime) or edge deployments where installing the runtime is not possible.
Kestrel is ASP.NET Core's built-in cross-platform HTTP server. It handles raw TCP/TLS connections and is fast enough for production traffic, but in most deployments it runs behind a reverse proxy (Nginx, Apache, or IIS) that handles TLS termination, static files, rate limiting, and connection management.
// Program.cs — Kestrel is the default; configure limits explicitly:
builder.WebHost.ConfigureKestrel(opts =>
{
opts.Limits.MaxConcurrentConnections = 1000;
opts.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB
opts.Limits.MinRequestBodyDataRate = null; // disable for uploads
opts.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
// HTTPS directly on Kestrel (without a reverse proxy):
opts.ListenAnyIP(443, listenOpts =>
listenOpts.UseHttps("/etc/ssl/certs/app.pfx", "cert-password"));
// HTTP/2 and HTTP/3:
opts.ListenAnyIP(5000, listenOpts =>
listenOpts.Protocols = HttpProtocols.Http1AndHttp2);
});
// Behind a reverse proxy — forward headers so HTTPS links are correct:
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
});
// Must come BEFORE UseAuthentication / UseRouting
// Nginx config snippet (reverse proxy to Kestrel on port 5000):
// server {
// listen 443 ssl;
// location / {
// proxy_pass http://localhost:5000;
// proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
// proxy_set_header X-Forwarded-Proto $scheme;
// }
// }
Rule of thumb: Always call UseForwardedHeaders when running behind a
reverse proxy. Without it, Request.Scheme returns http even for HTTPS
requests, breaking redirect URLs and cookie Secure flags.
ASP.NET Core layers configuration from multiple sources in priority order.
Higher-priority sources override lower ones. The ASPNETCORE_ENVIRONMENT
environment variable selects environment-specific files.
// Default configuration loading order (last wins):
// 1. appsettings.json — base config, committed to source control
// 2. appsettings.{Environment}.json — environment overrides (Development, Production)
// 3. Environment variables — injected by host OS / orchestrator / Docker
// 4. Command-line arguments — highest priority, useful for one-off overrides
// appsettings.json:
// { "ConnectionStrings": { "Default": "Server=localhost;Database=dev" } }
// appsettings.Production.json:
// { "ConnectionStrings": { "Default": "Server=prod-db;Database=app" } }
// Environment variables override any file:
// ConnectionStrings__Default=Server=prod-db;Database=app
// (double underscore = colon separator for nested keys)
// Access in code:
var connStr = builder.Configuration.GetConnectionString("Default");
// Secrets in development — user secrets (never committed):
// dotnet user-secrets set "ConnectionStrings:Default" "Server=localhost;..."
// Secrets in production — environment variables or Azure Key Vault:
builder.Configuration.AddAzureKeyVault(
new Uri($"https://{vaultName}.vault.azure.net/"),
new DefaultAzureCredential());
// ASPNETCORE_ENVIRONMENT controls which appsettings file is loaded:
// Development → appsettings.Development.json
// Staging → appsettings.Staging.json
// Production → appsettings.Production.json (default if not set)
Rule of thumb: Commit appsettings.json and environment-specific files
with non-sensitive defaults. Never commit secrets — use environment variables
in production and dotnet user-secrets in development.
A multi-stage Dockerfile uses the SDK image to build and the smaller runtime image to run, keeping the final image lean and free of build tools.
# Stage 1: restore and build (uses full SDK)
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy project files first to leverage layer caching:
COPY ["MyApp/MyApp.csproj", "MyApp/"]
RUN dotnet restore "MyApp/MyApp.csproj"
# Copy source and publish:
COPY . .
WORKDIR /src/MyApp
RUN dotnet publish "MyApp.csproj" -c Release -o /app/publish \
--no-restore
# Stage 2: final runtime image (no SDK, no build tools)
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
# Run as non-root (security hardening):
RUN useradd --uid 1001 --no-create-home appuser
USER appuser
# Copy only the publish output:
COPY --from=build /app/publish .
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApp.dll"]
# Build and run:
docker build -t myapp:latest .
docker run -p 8080:8080 \
-e ConnectionStrings__Default="Server=db;Database=app" \
myapp:latest
Multi-stage builds reduce the final image from ~1 GB (SDK) to ~250 MB (runtime). Running as a non-root user prevents the container process from escalating privileges if compromised.
Rule of thumb: Always copy the project file and run dotnet restore as a
separate COPY/RUN step before copying source code. Docker caches the restore
layer until the .csproj changes, making subsequent builds much faster.
Kubernetes uses liveness probes to restart an unhealthy container and readiness probes to stop routing traffic to a container that is not ready. ASP.NET Core's health checks map directly to these two probe types.
// Program.cs — separate endpoints for liveness and readiness:
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>(tags: ["readiness"])
.AddRedis("localhost:6379", tags: ["readiness"]);
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false, // always 200 if process is running
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("readiness"),
ResultStatusCodes =
{
[HealthStatus.Healthy] = StatusCodes.Status200OK,
[HealthStatus.Degraded] = StatusCodes.Status200OK, // still route traffic
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable,
},
});
# Kubernetes deployment — liveness and readiness probes:
spec:
containers:
- name: myapp
image: myapp:latest
ports:
- containerPort: 8080
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 5 # wait for startup
periodSeconds: 10
failureThreshold: 3 # restart after 3 consecutive failures
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 2 # pull from load balancer after 2 failures
Rule of thumb: Liveness checks should only verify the process is not deadlocked (no external dependency checks). Readiness checks should verify all dependencies the app needs to serve traffic are available. A liveness failure restarts the pod; a readiness failure only removes it from the load balancer.
Graceful shutdown lets the app finish in-flight requests before stopping. ASP.NET Core handles SIGTERM automatically but the default shutdown timeout (5 s) may be too short for long-running requests.
// Increase the shutdown timeout:
builder.Host.ConfigureHostOptions(opts =>
opts.ShutdownTimeout = TimeSpan.FromSeconds(30));
// Or per web host:
builder.WebHost.UseShutdownTimeout(TimeSpan.FromSeconds(30));
// IHostedService — implement long-running background work with cancellation:
public class OrderProcessorService : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var order = await _queue.DequeueAsync(stoppingToken);
if (order is null) continue;
try { await ProcessOrderAsync(order, stoppingToken); }
catch (OperationCanceledException) { break; } // shutting down — stop cleanly
catch (Exception ex) { _logger.LogError(ex, "Processing failed"); }
}
_logger.LogInformation("OrderProcessorService stopped gracefully");
}
}
// IHostApplicationLifetime — hook into specific lifecycle events:
public class StartupService : IHostedService
{
private readonly IHostApplicationLifetime _lifetime;
public Task StartAsync(CancellationToken ct)
{
_lifetime.ApplicationStopping.Register(() =>
_logger.LogInformation("Shutdown signal received"));
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}
In Kubernetes, a preStop hook can add a sleep before SIGTERM to let the
load balancer drain connections before the process starts shutting down:
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]
Rule of thumb: Always pass CancellationToken through all async operations
in background services. If you don't, a shutdown signal is ignored and the
process may be forcibly killed, dropping in-flight work.
Azure App Service hosts ASP.NET Core applications without managing virtual machines. Deployment can be done via GitHub Actions, Azure DevOps, or the Azure CLI.
# Create an App Service Plan and Web App via Azure CLI:
az group create --name myapp-rg --location eastus
az appservice plan create --name myapp-plan --resource-group myapp-rg \
--sku B1 --is-linux
az webapp create --resource-group myapp-rg --plan myapp-plan \
--name myapp-prod --runtime "DOTNETCORE|8.0"
# Deploy from local publish output:
dotnet publish -c Release -o ./publish
az webapp deployment source config-zip \
--resource-group myapp-rg --name myapp-prod \
--src ./publish.zip
# Configure app settings (environment variables):
az webapp config appsettings set --resource-group myapp-rg --name myapp-prod \
--settings "ConnectionStrings__Default=Server=..." \
"ASPNETCORE_ENVIRONMENT=Production"
# GitHub Actions workflow:
- name: Deploy to Azure App Service
uses: azure/webapps-deploy@v3
with:
app-name: myapp-prod
publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
package: ./publish
Key App Service features for .NET:
- Deployment slots — blue/green deployments with warm-up
- Auto-scaling — scale out based on CPU or HTTP queue depth
- Managed Identity — access Key Vault without storing credentials
- Health check integration — automatically restart unhealthy instances
Rule of thumb: Use App Service deployment slots for zero-downtime releases — deploy to a staging slot, warm it up, then swap to production in a single atomic operation.
GitHub Actions provides first-class .NET support via actions/setup-dotnet.
A typical pipeline builds, tests, and deploys the application on every push
to main.
# .github/workflows/deploy.yml
name: Build and Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore -c Release
- name: Test
run: dotnet test --no-build -c Release \
--collect:"XPlat Code Coverage" \
--results-directory ./coverage
- name: Publish coverage
uses: codecov/codecov-action@v4
with:
directory: ./coverage
- name: Publish
run: dotnet publish src/MyApp/MyApp.csproj \
-c Release -o ./publish
- name: Upload artifact
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' # only deploy on main push
environment: production # requires manual approval if configured
steps:
- uses: actions/download-artifact@v4
with:
name: publish
path: ./publish
- name: Deploy to Azure
uses: azure/webapps-deploy@v3
with:
app-name: myapp-prod
publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
package: ./publish
Rule of thumb: Separate build-and-test and deploy into two jobs with
needs. This ensures that tests pass before deployment runs, and the deploy
job can require manual approval for production via GitHub Environments.
Never store secrets in source code or committed configuration files. .NET has several mechanisms for injecting secrets at runtime.
// Development: dotnet user-secrets (per-developer, never committed)
// dotnet user-secrets init
// dotnet user-secrets set "ConnectionStrings:Default" "Server=localhost..."
// Stored in ~/.microsoft/usersecrets/<project-guid>/secrets.json
// Production option 1: environment variables (simplest)
// Set in the OS, Docker, or Kubernetes:
// CONNECTIONSTRINGS__DEFAULT=Server=prod-db;...
// (double underscore maps to colon in .NET config)
// Production option 2: Azure Key Vault (recommended for Azure deployments)
// dotnet add package Azure.Extensions.AspNetCore.Configuration.Secrets
builder.Configuration.AddAzureKeyVault(
new Uri($"https://{vaultName}.vault.azure.net/"),
new DefaultAzureCredential()); // uses Managed Identity in Azure, dev credentials locally
// Production option 3: Kubernetes secrets
// kubectl create secret generic myapp-secrets \
// --from-literal=ConnectionStrings__Default="Server=prod-db;..."
// Mounted as environment variables in the pod spec:
// env:
// - name: ConnectionStrings__Default
// valueFrom:
// secretKeyRef:
// name: myapp-secrets
// key: ConnectionStrings__Default
// Validate required configuration at startup — fail fast:
builder.Services.AddOptions<DatabaseOptions>()
.BindConfiguration("Database")
.ValidateDataAnnotations()
.ValidateOnStart(); // throw on startup if required fields are missing
Rule of thumb: Always use ValidateOnStart() on critical configuration.
A startup crash with a clear message ("ConnectionString is required") is far
better than a runtime NullReferenceException discovered under load.
.NET ships several built-in diagnostic tools that work on Linux and Windows without attaching a GUI profiler to production.
# dotnet-trace — capture a CPU and allocation trace:
dotnet tool install --global dotnet-trace
dotnet-trace collect --process-id <pid> --duration 00:00:30 \
--profile cpu-sampling
# Analyze with PerfView or SpeedScope (speedscope.app):
dotnet-trace convert trace.nettrace --format Speedscope
# dotnet-dump — capture and analyze a memory dump:
dotnet tool install --global dotnet-dump
dotnet-dump collect --process-id <pid>
dotnet-dump analyze core_20260623.dump
> dumpheap -stat # top types by allocation count
> gcroot <address> # find what's keeping an object alive
# dotnet-counters — live metrics in the terminal:
dotnet tool install --global dotnet-counters
dotnet-counters monitor --process-id <pid> \
System.Runtime Microsoft.AspNetCore.Hosting
# Outputs real-time:
# [System.Runtime]
# GC Heap Size (MB) 87
# Gen 0 GC Count 42
# ThreadPool Queue Length 0
# CPU Usage (%) 12
// In code — BenchmarkDotNet for micro-benchmarks:
[MemoryDiagnoser]
public class StringBenchmarks
{
[Benchmark(Baseline = true)]
public string Interpolation() => $"Hello {name}!";
[Benchmark]
public string SpanBased() =>
string.Create(6 + name.Length, name, (buf, n) =>
{
"Hello ".AsSpan().CopyTo(buf);
n.AsSpan().CopyTo(buf[6..]);
buf[^1] = '!';
});
}
Rule of thumb: Profile first, optimize second. Use dotnet-counters to
spot elevated GC pressure or thread pool starvation before reaching for a
full trace. Most .NET performance problems are GC allocation issues, not
algorithmic complexity — [MemoryDiagnoser] in BenchmarkDotNet reveals
allocations per operation.
Shipping to production involves configuration, observability, security hardening,
and infrastructure concerns beyond just running dotnet publish.
// 1. Environment is set to Production:
// ASPNETCORE_ENVIRONMENT=Production
// (disables developer exception pages, enables compressed responses)
// 2. Secrets are in environment variables or Key Vault — NOT in committed files
// 3. HTTPS enforced:
app.UseHttpsRedirection();
app.UseHsts(); // sends Strict-Transport-Security header
// 4. Security headers:
app.Use(async (ctx, next) =>
{
ctx.Response.Headers.Append("X-Content-Type-Options", "nosniff");
ctx.Response.Headers.Append("X-Frame-Options", "DENY");
ctx.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
await next();
});
// 5. Response compression (saves bandwidth):
builder.Services.AddResponseCompression(opts =>
opts.EnableForHttps = true);
app.UseResponseCompression();
// 6. Health check endpoints registered and tested (see health-probe-kubernetes)
// 7. Structured logging to a central sink (Seq, Application Insights, ELK)
// 8. Database migrations run before startup (not during startup in production):
// Run as a separate job/init container:
// dotnet ef database update --project MyApp.Data
// 9. Connection pool tuning:
// "MaxPoolSize=200;Min Pool Size=10;Connection Timeout=30"
// 10. Data protection key ring persisted (ASP.NET Core cookie/JWT signing keys):
builder.Services.AddDataProtection()
.PersistKeysToAzureBlobStorage(blobClient)
.ProtectKeysWithAzureKeyVault(keyIdentifier, credential);
Rule of thumb: The most common production bugs are: wrong environment name
(still Development), secrets not injected, data protection keys not shared
between instances (cookie auth breaks), and migrations not run. Verify all
four before every first-time production deployment.
Blue-green deployment runs two identical production environments (blue and green). Traffic is switched from the current (blue) to the new (green) in a single atomic step. Rolling deployment gradually replaces old instances with new ones, keeping some capacity available throughout.
# Kubernetes rolling deployment (default strategy):
spec:
replicas: 4
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1 # at most 1 pod down at a time
maxSurge: 1 # at most 1 extra pod during rollout
template:
spec:
containers:
- name: myapp
image: myapp:v2 # update this field to trigger a rollout
# Trigger a rolling update by updating the image:
kubectl set image deployment/myapp myapp=myapp:v2
# Monitor rollout status:
kubectl rollout status deployment/myapp
# Roll back if the new version is unhealthy:
kubectl rollout undo deployment/myapp
// Blue-green via Azure App Service deployment slots:
// 1. Deploy new version to the "staging" slot (pre-warmed, no traffic):
// az webapp deployment source config-zip --slot staging ...
// 2. Swap slots atomically — staging becomes production:
// az webapp deployment slot swap --slot staging --target-slot production
// 3. If issues arise, swap back in seconds:
// az webapp deployment slot swap --slot production --target-slot staging
// Slot swap warm-up — ASP.NET Core startup hooks:
builder.Services.Configure<IISServerOptions>(opts =>
opts.AutomaticAuthentication = false);
// The slot swap waits for the app to return 200 on /health/ready
// before completing the swap:
app.MapHealthChecks("/health/ready");
Rule of thumb: Use rolling updates in Kubernetes for normal deploys — they are zero-downtime by default. Use blue-green (deployment slots or a second Kubernetes deployment) when you need an instant rollback path with no traffic impact.
Running database update inside the application startup (DbContext.Migrate())
is risky in multi-instance deployments because all instances race to apply the
same migration simultaneously. The safer pattern is to apply migrations as a
dedicated step before the application starts.
// Avoid this in production — causes races with multiple instances:
// app.Services.GetRequiredService<AppDbContext>().Database.Migrate();
// Safe pattern 1: migration as a separate CLI step (GitHub Actions / Azure Pipelines):
// dotnet ef database update --project MyApp.Data --startup-project MyApp
// Run this AFTER the new binaries are deployed but BEFORE traffic is switched.
// Safe pattern 2: dedicated migrator project / job:
// Create a console app that only runs migrations, then exits:
var app = Host.CreateDefaultBuilder(args)
.ConfigureServices((ctx, services) =>
services.AddDbContext<AppDbContext>(opts =>
opts.UseNpgsql(ctx.Configuration.GetConnectionString("Default"))))
.Build();
await using var scope = app.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
// Process exits here — used as a Kubernetes init container
# Kubernetes init container — runs migrations before the app pod starts:
spec:
initContainers:
- name: db-migrate
image: myapp:v2
command: ["dotnet", "MyApp.Migrator.dll"]
env:
- name: ConnectionStrings__Default
valueFrom:
secretKeyRef:
name: myapp-secrets
key: db-connection
containers:
- name: myapp
image: myapp:v2
# App container starts only after init container succeeds
Write backward-compatible migrations: add nullable columns or columns with defaults first, deploy the app, then tighten constraints in a follow-up migration. This keeps old and new code running against the same schema simultaneously.
Rule of thumb: Never call Migrate() in Program.cs in production. Run
migrations as an init container or a pipeline step. If the migration fails, the
app pods should not start — this prevents serving traffic against a broken schema.
Data Protection generates the keys used to encrypt cookies, anti-forgery tokens, and TempData. By default keys are stored in memory and are unique per process. In a multi-instance deployment, instance A cannot decrypt a cookie encrypted by instance B, causing authentication failures and anti-forgery errors.
// Problem — default in-memory keys: each pod has different keys
// User authenticates on pod A, next request goes to pod B → 401 or CSRF failure
// Solution: share keys across all instances via a common store + key ring protection
// Option 1: Azure Blob Storage + Azure Key Vault (recommended for Azure):
// dotnet add package Azure.Extensions.AspNetCore.DataProtection.Blobs
// dotnet add package Azure.Extensions.AspNetCore.DataProtection.Keys
builder.Services.AddDataProtection()
.PersistKeysToAzureBlobStorage(
new Uri("https://myaccount.blob.core.windows.net/keys/data-protection.xml"),
new DefaultAzureCredential())
.ProtectKeysWithAzureKeyVault(
new Uri("https://myvault.vault.azure.net/keys/data-protection-key"),
new DefaultAzureCredential())
.SetApplicationName("myapp"); // isolates keys from other apps in the same store
// Option 2: File system share (shared NFS / Azure Files):
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("/mnt/shared/keys"))
.ProtectKeysWithCertificate(certificate);
// Option 3: Redis (StackExchange.Redis):
// dotnet add package Microsoft.AspNetCore.DataProtection.StackExchangeRedis
builder.Services.AddDataProtection()
.PersistKeysToStackExchangeRedis(redisConnection, "DataProtection-Keys")
.SetApplicationName("myapp");
// Option 4: SQL Server / EF Core:
// dotnet add package Microsoft.AspNetCore.DataProtection.EntityFrameworkCore
builder.Services.AddDataProtection()
.PersistKeysToDbContext<AppDbContext>();
Key ring settings to review:
SetApplicationName— required when multiple apps share the same key storeSetDefaultKeyLifetime— keys rotate every 90 days by defaultDisableAutomaticKeyGeneration— use only in secondary read-only instances
Rule of thumb: Always configure a shared key store when running more than one instance of an ASP.NET Core app. The most common symptom of missing key sharing is intermittent 400 errors on form submissions (anti-forgery) or users being logged out randomly (cookie decryption fails).
The .NET runtime reads CPU and memory limits from cgroup information set by Docker or Kubernetes. Misconfigured limits cause GC thrashing, thread pool starvation, or OOMKilled pods.
# Kubernetes resource requests and limits:
resources:
requests:
memory: "256Mi" # minimum guaranteed — used for scheduling
cpu: "250m" # 0.25 cores guaranteed
limits:
memory: "512Mi" # hard cap — OOMKill if exceeded
cpu: "1000m" # 1 core max (throttled, not killed)
// .NET automatically sizes GC and thread pool based on cgroup limits
// (requires .NET 3.0+ with cgroup v1, .NET 6+ for cgroup v2):
// Verify the runtime sees correct limits at startup:
var cpuCount = Environment.ProcessorCount; // reflects cgroup CPU quota
var gcHeapBytes = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes; // reflects limit
_logger.LogInformation("CPU count: {Cpu}, GC heap limit: {Heap} MB",
cpuCount, gcHeapBytes / 1024 / 1024);
// Server GC (default) — one heap per CPU core; bad with low limits:
// With 0.25 cores, Server GC still allocates heaps based on physical cores.
// Switch to Workstation GC for low-CPU containers:
// In .csproj:
// <ServerGarbageCollection>false</ServerGarbageCollection>
// Or via runtimeconfig.json:
// { "configProperties": { "System.GC.Server": false } }
// Thread pool: defaults to min threads = CPU count.
// With 0.25 vCPU, min threads = 1 by default. Tune if needed:
ThreadPool.SetMinThreads(workerThreads: 4, completionPortThreads: 4);
Common pitfalls:
- No memory limit — GC uses host total RAM, OOMKilled when the pod exceeds the node's available memory
- CPU throttling —
cpu: limits: 250mmeans the container is throttled when it exceeds 250 ms of CPU per 1000 ms; causes latency spikes, not crashes - Server GC + low CPU — Server GC creates one thread per logical core; with many small-CPU containers this is wasteful
Rule of thumb: Set memory limits to 1.5–2x the normal heap size observed
in dotnet-counters. Set CPU limits to at least 1 core for latency-sensitive
services — CPU throttling introduces tail-latency spikes that are hard to
diagnose.
More Performance & Deployment interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.