Deploy ASP.NET Core to Production: A Practical Guide

Khimananda Oli 7 min read Programming and Languages
Deploy ASP.NET Core to Production: A Practical Guide

By Khimananda Oli | Last reviewed: August 2026

Teams often build robust APIs but stumble when they deploy ASP.NET Core to production on Linux because the runtime behaves differently than in Visual Studio. A common mistake is running Kestrel directly as a public-facing server or storing secrets in appsettings.json, both of which create security and reliability risks. This guide covers the exact configuration I use to ship .NET 9 applications safely behind Nginx with full observability.

Internet ClientHTTPS :443Nginx ProxyTLS TerminationRate LimitingStatic AssetsPort 80/443ASP.NET CoreKestrel :5000Systemd ManagedPostgreSQLPrivate Subnet
Production architecture to deploy ASP.NET Core to production safely behind Nginx with managed database connectivity.

How do you configure Nginx as a reverse proxy for ASP.NET Core?

Kestrel is an excellent application server, but it lacks the battle-hardened edge features required for public traffic. When you install Nginx on Ubuntu as a reverse proxy, you gain TLS termination, HTTP/2 support, request buffering, and protection against slow-loris attacks. The key is forwarding headers correctly so your application sees the real client IP and protocol.

Nginx site configuration

Create /etc/nginx/sites-available/aspnet-app with explicit header forwarding. Never rely on default proxy settings in production.

server {
    listen 80;
    server_name api.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name api.example.com;

    ssl_certificate     /etc/letsencrypt/live/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;

    location / {
        proxy_pass         http://127.0.0.1:5000;
        proxy_http_version 1.1;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection keep-alive;
        proxy_set_header   Host $host;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
        proxy_buffering    on;
        proxy_read_timeout 90s;
    }

    location /health {
        proxy_pass http://127.0.0.1:5000/health;
        access_log off;
    }
}

Configure forwarded headers in .NET

Your application must trust the proxy and map headers to HttpContext. Add this early in Program.cs, before authentication or routing middleware:

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor 
                             | ForwardedHeaders.XForwardedProto;
    options.KnownNetworks.Clear();
    options.KnownProxies.Add(IPAddress.Parse("127.0.0.1"));
});

app.UseForwardedHeaders();

A common mistake is placing UseForwardedHeaders() after UseAuthentication(). This causes HTTPS redirects to loop because the auth middleware sees HTTP instead of the original scheme. Always verify header order matches the official Microsoft documentation.

How do you create a secure systemd service for ASP.NET Core?

Running your app as a background process with nohup or screen sessions is not production-grade. Systemd provides automatic restarts, resource limits, logging integration, and security sandboxing. For teams managing compliance like SOC 2, systemd unit files serve as auditable configuration artifacts that demonstrate least-privilege execution.

Hardened unit file

Create /etc/systemd/system/aspnet-app.service with security directives enabled:

[Unit]
Description=ASP.NET Core Production API
After=network.target postgresql.service
Wants=postgresql.service

[Service]
User=aspnet-app
Group=aspnet-app
WorkingDirectory=/opt/aspnet-app/current
ExecStart=/opt/aspnet-app/current/MyApp --urls=http://127.0.0.1:5000
Restart=always
RestartSec=10
KillSignal=SIGINT
SyslogIdentifier=aspnet-app

Environment=ASPNETCORE_ENVIRONMENT=Production
EnvironmentFile=/opt/aspnet-app/env.production

ProtectSystem=strict
ProtectHome=true
NoNewPrivileges=true
PrivateTmp=true
ReadWritePaths=/opt/aspnet-app/logs

[Install]
WantedBy=multi-user.target

Why these security directives matter

  • NoNewPrivileges=true prevents child processes from escalating privileges via setuid binaries.
  • ProtectSystem=strict makes the entire filesystem read-only except paths in ReadWritePaths.
  • EnvironmentFile keeps secrets out of the unit file itself, allowing separate permission management.
  • SyslogIdentifier ensures logs route correctly to journald for centralized collection with tools like Graylog centralized log management.

Always create a dedicated system user (useradd -r -s /usr/sbin/nologin aspnet-app) rather than running as root or a developer account. This limits blast radius if the application is compromised.

How should you manage secrets and configuration in production?

Never commit appsettings.Production.json with real credentials to source control. Even encrypted config files add operational complexity during rotation. Environment variables remain the most portable and audit-friendly approach for injecting secrets at runtime.

Environment file structure

Store production secrets in /opt/aspnet-app/env.production with restrictive permissions:

ConnectionStrings__Default=Host=db.internal;Database=myapp;Username=app_user;Password=${DB_PASSWORD}
Jwt__SigningKey=${JWT_SIGNING_KEY}
Redis__ConnectionString=redis.internal:6379,password=${REDIS_PASS}
Serilog__WriteTo__1__Args__connectionString=${SEQ_CONNECTION}
sudo chown root:aspnet-app /opt/aspnet-app/env.production
sudo chmod 640 /opt/aspnet-app/env.production

The double-underscore (__) syntax maps nested JSON keys to flat environment variables, which .NET parses automatically. For cloud-native deployments, integrate with HashiCorp Vault or AWS Secrets Manager and populate this file during deployment rather than storing long-lived credentials on disk.

Git Pushmain branchBuild & Testdotnet testTrivy scanPublishself-containedlinux-x64ApprovalManual gateSlack notifyDeployrsync + reloadHealth check
Automated CI/CD workflow to deploy ASP.NET Core to production with security scanning and manual approval gates.

What deployment strategy minimizes downtime for ASP.NET Core?

Directly overwriting running binaries causes failed requests during file locks. Use atomic directory swaps with symlink redirection to achieve zero-downtime releases without complex orchestration.

Atomic deployment script

  1. Publish to a timestamped directory: /opt/aspnet-app/releases/20260820-1430/
  2. Run smoke tests against the new release on localhost:5001 before switching traffic.
  3. Update the /opt/aspnet-app/current symlink atomically: ln -sfn /opt/aspnet-app/releases/20260820-1430 /opt/aspnet-app/current
  4. Reload the service gracefully: systemctl reload-or-restart aspnet-app
  5. Verify health endpoint returns 200 within 30 seconds; rollback symlink if not.

This approach keeps previous releases available for instant rollback. Retain at least three releases and automate cleanup via cron. For teams adopting GitOps patterns similar to those in setting up GitOps with ArgoCD, adapt this pattern to pull artifacts from your registry rather than pushing directly.

How do you compare hosting options when you deploy ASP.NET Core to production?

CriteriaLinux VM + NginxAzure App ServiceKubernetes (AKS/EKS)
Setup complexityModerate (manual hardening)Low (managed platform)High (cluster ops required)
Cost at low scaleLow ($5–20/mo VPS)Medium ($50+/mo minimum)High ($100+/mo base)
Auto-scaling granularityManual or custom scriptsBuilt-in metric-basedHPA/VPA with fine control
Compliance evidenceSelf-managed audit trailsMicrosoft compliance docsFull infrastructure-as-code
Best forSMEs, Nepal-local hostingEnterprise Azure shopsMicroservices at scale

For Nepali businesses targeting local users with budget constraints, a well-hardened Ubuntu VPS remains cost-effective. Global SaaS products benefit from managed platforms that reduce operational overhead. Choose based on team size, compliance requirements, and traffic predictability—not hype.

❌ Anti-Pattern: Direct KestrelInternetKestrel :443No rate limitingNo request buffering✓ Correct: Nginx + Kestrel + SystemdInternetNginx :443TLS + BufferingKestrel :5000Localhost onlySystemdAuto-restart
Visual comparison demonstrating why direct Kestrel exposure fails and how proper layering secures ASP.NET Core deployments.

Deploy ASP.NET Core to Production with Confidence

Shipping .NET applications to Linux servers requires treating the runtime as one component in a layered defense strategy. Configure Nginx explicitly, harden systemd units, externalize secrets, and automate deployments with atomic swaps. Monitor endpoints continuously using the four golden signals of monitoring to catch regressions before users report them. If your team needs help designing compliant .NET infrastructure or auditing existing deployments, reach out to discuss your specific requirements.

Frequently Asked Questions

Kestrel is the default cross-platform server, but production deployments should use a reverse proxy like Nginx or Caddy. This setup handles SSL termination, load balancing, and static file caching while Kestrel focuses solely on application processing behind the proxy layer.

Use appsettings.Production.json files or environment variables prefixed with ASPNETCORE_. Environment variables override JSON config at runtime, allowing secure secret injection via CI/CD pipelines without embedding credentials in source code or container images during the build process.

No, IIS is optional. You can host ASP.NET Core as a self-contained Windows Service using sc.exe or run it directly behind Nginx on Linux. IIS acts only as a reverse proxy when used, not as the primary application runtime.

Never store secrets in appsettings.json. Use Azure Key Vault, AWS Secrets Manager, or HashiCorp Vault with managed identity authentication. Inject secrets as environment variables at deployment time so they never touch disk or version control systems.

Add app.UseHttpsRedirection() in Program.cs and configure HSTS headers. Ensure your reverse proxy terminates TLS correctly and forwards the X-Forwarded-Proto header so Kestrel recognizes secure requests and applies redirect logic properly.

Yes, publish with dotnet publish -c Release --self-contained true /p:PublishSingleFile=true. This bundles the runtime and app into one binary, simplifying deployment but increasing file size significantly compared to framework-dependent deployments.

Add the Microsoft.AspNetCore.Diagnostics.HealthChecks package and map /health endpoints. Configure liveness and readiness probes separately so orchestrators like Kubernetes distinguish between app startup failures and transient dependency issues during rolling updates.

Use structured logging with Serilog or NLog writing to stdout/stderr. Container platforms and cloud providers capture standard streams natively. Avoid file-based logging in containers since ephemeral storage loses logs during restarts or scaling events.

Enable ReadyToRun compilation with /p:PublishReadyToRun=true during publish. This pre-compiles IL to native code, reducing JIT overhead at startup. Combine with trimming unused assemblies to decrease both cold start latency and memory footprint.

Docker provides consistent environments and easier scaling but adds orchestration complexity. Bare metal offers lower latency and simpler debugging. Choose Docker if you need portability across clouds; choose bare metal for latency-sensitive monoliths with stable infrastructure.

Run migrations separately from app startup using dotnet ef database update in CI/CD before deploying new code. Never auto-migrate on application start in production since concurrent instances may attempt simultaneous schema changes causing locks or data corruption.

Typically the reverse proxy cannot reach Kestrel. Check if the app crashed, listen ports mismatch proxy config, or firewall rules block localhost traffic. Review systemd journal or container logs for unhandled exceptions preventing Kestrel from binding.

ASP.NET Core handles SIGTERM automatically, cancelling pending requests within the configured timeout. Set ShutdownTimeout in host builder and ensure long-running background services implement IHostedService.StopAsync to complete work before the process exits.

Yes, Native AOT is production-ready for minimal APIs and gRPC services in .NET 9+. It eliminates JIT entirely, reducing memory to under 50MB and startup to milliseconds. MVC and EF Core still have limited AOT support requiring source generators.

Enable OpenTelemetry tracing and metrics collection. Export to Prometheus, Grafana, or Application Insights. Track request duration percentiles, GC pressure, thread pool saturation, and database query times to identify bottlenecks before users experience degradation.