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_URLSbinds Kestrel to localhost only.KillSignal=SIGINTlets the app shut down gracefully.- Put connection strings in
/etc/myapp/secrets.env(mode 600), usingConnectionStrings__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 —
UseForwardedHeadersmissing or placed too late. - Users logged out after restart — persist Data Protection keys.
System.Globalization.Invarianterrors — 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.