ASP.NET Core apps run on Kestrel, a fast cross-platform web server built into .NET. In production on Linux, the standard setup is Kestrel bound to localhost, supervised by systemd, with Nginx in front handling HTTPS, compression and static files.

1. Install the runtime on the server

sudo apt update
sudo apt install -y aspnetcore-runtime-8.0
dotnet --list-runtimes

See installing .NET on Linux for other distributions and versions.

2. Publish the app

On your machine or in CI:

dotnet publish -c Release -o ./publish

This produces framework-dependent output (MyApp.dll plus dependencies). Copy it to the server:

rsync -az --delete ./publish/ [email protected]:/var/www/myapp/

3. Handle forwarded headers in the app

Behind a proxy, the app sees requests from 127.0.0.1 over HTTP. Configure forwarded headers in Program.cs so redirects, HTTPS detection and client IPs are correct:

using Microsoft.AspNetCore.HttpOverrides;

var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    // Nginx on the same host (127.0.0.1) is trusted by default
});

var app = builder.Build();
app.UseForwardedHeaders();     // early in the pipeline
// app.UseHttpsRedirection();  // optional: Nginx already redirects to HTTPS
app.MapGet("/health", () => Results.Ok());
app.Run();

4. systemd service

sudo useradd --system --no-create-home --shell /usr/sbin/nologin myapp
sudo chown -R myapp:myapp /var/www/myapp

/etc/systemd/system/myapp.service:

[Unit]
Description=MyApp ASP.NET Core
After=network.target

[Service]
User=myapp
WorkingDirectory=/var/www/myapp
ExecStart=/usr/bin/dotnet /var/www/myapp/MyApp.dll
Restart=always
RestartSec=5
KillSignal=SIGINT
SyslogIdentifier=myapp
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=ASPNETCORE_URLS=http://127.0.0.1:5000
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
EnvironmentFile=-/etc/myapp/secrets.env

[Install]
WantedBy=multi-user.target
  • ASPNETCORE_URLS binds Kestrel to localhost only.
  • KillSignal=SIGINT lets the app shut down gracefully.
  • Put connection strings in /etc/myapp/secrets.env (mode 600), using ConnectionStrings__Default=… (double underscore maps to nested config keys).
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
journalctl -u myapp -f
curl http://127.0.0.1:5000/health

5. Nginx

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://127.0.0.1:5000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }

    client_max_body_size 30M;
}

Define $connection_upgrade with a map in http {} (see WebSockets through Nginx) — needed for SignalR and Blazor Server.

sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d example.com

6. Data Protection keys

ASP.NET Core encrypts cookies and anti-forgery tokens with Data Protection keys. By default on Linux they’re stored in the user’s home directory — with a nologin system user without a home, keys may be ephemeral, logging users out on every restart. Persist them:

builder.Services.AddDataProtection()
    .PersistKeysToFileSystem(new DirectoryInfo("/var/lib/myapp/keys"));

Create the directory owned by myapp. With several servers, share keys (database, Redis or a shared directory).

7. Deploy updates

rsync -az --delete ./publish/ deploy@server:/var/www/myapp.new/
ssh deploy@server 'sudo rsync -a --delete /var/www/myapp.new/ /var/www/myapp/ && sudo systemctl restart myapp'

A restart causes a brief interruption; for zero downtime, run two instances on different ports behind an Nginx upstream and restart them one at a time — see blue-green deployment.

8. Docker option

Microsoft publishes slim runtime images (mcr.microsoft.com/dotnet/aspnet:8.0); a multi-stage Dockerfile with the SDK image for building works the same as for other stacks — see multi-stage builds.

Troubleshooting

  • 502 Bad Gateway — app not running or bound elsewhere; check journalctl -u myapp.
  • Redirect loops or wrong scheme — UseForwardedHeaders missing or placed too late.
  • Users logged out after restart — persist Data Protection keys.
  • System.Globalization.Invariant errors — install ICU (libicu) or enable invariant mode.

A Linux VPS is typically far cheaper than Windows hosting for .NET apps — see our Ubuntu VPS plans.

  • ASP.NET Core
  • .NET
  • Nginx