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):
appsettings.json— base config, committed to source controlappsettings.{Environment}.json— environment-specific overrides- Environment variables — injected by OS, Docker, or Kubernetes
- 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:
| Environment | Secret source |
|---|---|
| Development | dotnet user-secrets |
| Docker | -e flag or Docker secrets |
| Kubernetes | Kubernetes Secrets via env vars |
| Azure | Azure 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.