Deploy ASP.NET Core to a Linux VPS
How to deploy ASP.NET Core to Linux: install .NET 10 on Ubuntu 24.04, publish, run Kestrel under systemd behind nginx with SSL, by hand or with ZoPanel.

Key takeaways
- On Ubuntu 24.04, .NET 10 and .NET 8 come from Ubuntu's own packages; Microsoft's feed no longer carries .NET for that release.
- .NET 8 and .NET 9 both reach end of support on 10 November 2026, so new deployments should target .NET 10 LTS.
- Kestrel should listen on 127.0.0.1 behind nginx, with the forwarded headers middleware enabled so the app sees https and the visitor's IP.
- Persist the Data Protection key ring outside the release folder, or every restart logs users out.
To deploy ASP.NET Core to Linux, you publish the app with dotnet publish -c Release, install the ASP.NET Core runtime on the server (or publish self-contained), run the app under systemd with Kestrel bound to 127.0.0.1, and put nginx in front for the domain and SSL. Two settings catch most people out: the forwarded headers middleware, so the app knows the request was HTTPS, and a persistent Data Protection key store, so a restart does not log everyone out. On Ubuntu 24.04 the whole setup takes under an hour by hand.
This guide does it step by step on Ubuntu 24.04, then shows the same job in ZoPanel, and ends with Windows/IIS and Azure App Service as alternatives.
Which .NET version should you deploy in October 2026?
Target .NET 10. According to Microsoft's .NET support policy, LTS releases get three years of support and STS releases two, and the current dates are:
| Version | Type | Released | End of support |
|---|---|---|---|
| .NET 10 | LTS | 11 November 2025 | 14 November 2028 |
| .NET 9 | STS | 12 November 2024 | 10 November 2026 |
| .NET 8 | LTS | 14 November 2023 | 10 November 2026 |
At the time of writing (October 2026), .NET 8 and .NET 9 have less than a month of patches left. If your app still targets net8.0 or net9.0, plan the move to net10.0 now; for most web apps it is a change to <TargetFramework> plus updated NuGet packages.
Before you start
- An Ubuntu 24.04 VPS with sudo. A small ASP.NET Core API runs in 1 GB of RAM; building on the server needs more.
- A domain with an A record pointing at the server. See how to point a domain to a VPS.
- The security basics done first: SSH keys, firewall, automatic updates. Our guide to securing an Ubuntu VPS has the list.
How to deploy ASP.NET Core to Linux by hand
Step 1: Install the ASP.NET Core runtime
On Ubuntu 24.04, Microsoft's install guide for Ubuntu says .NET 10 and .NET 8 are in Ubuntu's built-in feed, .NET 9 is in the ppa:dotnet/backports repository, and the Microsoft package repository no longer contains .NET packages for Ubuntu. So no extra repository is needed for .NET 10:
sudo apt-get update
sudo apt-get install -y aspnetcore-runtime-10.0
dotnet --list-runtimes
aspnetcore-runtime-10.0 contains both the .NET runtime and ASP.NET Core. If you build on the server too, install dotnet-sdk-10.0 instead; the SDK includes the runtime. Do not mix the Ubuntu feed with packages from another feed: Microsoft documents this as a known source of package mix-ups.
Step 2: Publish the app
Run this on your machine or in CI:
dotnet publish -c Release -o ./publish
That produces a framework-dependent build: your DLLs, without the runtime. The alternative is a self-contained build that carries its own runtime:
dotnet publish -c Release -r linux-x64 --self-contained -o ./publish
| Framework-dependent | Self-contained | |
|---|---|---|
| Runtime on the server | Required (aspnetcore-runtime-10.0) |
Not required |
| Size | Small (your code only) | Larger: includes the runtime |
| Runtime security patches | apt upgrade and restart |
Rebuild and redeploy the app |
| Start command | dotnet MyApp.dll |
./MyApp |
| Architecture | Any (same build for x64 and ARM64) | One per RID (linux-x64, linux-arm64) |
Microsoft's Linux and nginx hosting guide uses framework-dependent deployment, and so does this guide: Ubuntu's security updates then patch the runtime for every app at once. A self-contained app still needs system libraries such as libicu74 and libssl3t64, which the install guide lists.
Step 3: Copy it to a dedicated user
Never run the app as root. Create a system user, a releases folder and a separate data folder:
sudo adduser --system --group --home /srv/myapp myapp
sudo mkdir -p /srv/myapp/releases /var/lib/myapp/keys
sudo chown -R myapp:myapp /srv/myapp /var/lib/myapp
sudo chmod 700 /var/lib/myapp/keys
From your machine, upload each build into its own folder and point a current symlink at it:
REL=$(date +%Y%m%d%H%M%S)
rsync -a ./publish/ deploy@server:/tmp/myapp-$REL/
ssh deploy@server "sudo mv /tmp/myapp-$REL /srv/myapp/releases/$REL && \
sudo chown -R myapp:myapp /srv/myapp/releases/$REL && \
sudo ln -sfn /srv/myapp/releases/$REL /srv/myapp/current"
Step 4: Make the app proxy-aware and persist its keys
Two changes in Program.cs. First, nginx terminates SSL and sends plain HTTP to Kestrel, so the app must read X-Forwarded-Proto and X-Forwarded-For. Microsoft's guide says to call UseForwardedHeaders before other middleware; without options, no headers are forwarded. Loopback proxies such as 127.0.0.1 are trusted by default.
Second, the Data Protection key ring signs authentication cookies and antiforgery tokens. When none of the default storage locations applies, the keys live only in memory, and Microsoft's hosting guide warns that a restart then invalidates cookie sign-ins and CSRF tokens. Store them on disk, outside the release folder:
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("/var/lib/myapp/keys"))
.SetApplicationName("myapp");
var app = builder.Build();
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
// ... the rest of your middleware and endpoints
app.Run();
Microsoft notes that pointing Data Protection at your own folder turns off automatic encryption of keys at rest, which is why the folder above is chmod 700 and owned by the app user.
Step 5: Run Kestrel under systemd
Put secrets in a root-only file. ASP.NET Core reads environment variables as configuration, with __ in place of ::
sudo tee /etc/myapp.env >/dev/null <<'EOF'
ConnectionStrings__Default=Server=127.0.0.1;Database=myapp;User=myapp;Password=change-me
EOF
sudo chmod 600 /etc/myapp.env
Create /etc/systemd/system/myapp.service, based on the unit in Microsoft's guide:
[Unit]
Description=My ASP.NET Core app
After=network.target
[Service]
User=myapp
Group=myapp
WorkingDirectory=/srv/myapp/current
ExecStart=/usr/bin/dotnet /srv/myapp/current/MyApp.dll
Restart=always
RestartSec=10
KillSignal=SIGTERM
TimeoutStopSec=90
SyslogIdentifier=myapp
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=ASPNETCORE_URLS=http://127.0.0.1:5000
Environment=DOTNET_NOLOGO=true
EnvironmentFile=/etc/myapp.env
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/var/lib/myapp
[Install]
WantedBy=multi-user.target
A few lines matter:
ASPNETCORE_URLS=http://127.0.0.1:5000binds Kestrel to localhost only. RemoveUseUrls(...)orKestrel:Endpointsinappsettings.json, which would override it.KillSignal=SIGTERMis systemd's default, and the current Microsoft guide says to keep it: .NET listens forSIGTERMto shut down gracefully, and it now advises against theSIGINTthat older versions of the guide used.ASPNETCORE_ENVIRONMENT=Productionloadsappsettings.Production.json. Linux file names are case-sensitive.
Optionally, add the Microsoft.Extensions.Hosting.Systemd package and call builder.Host.UseSystemd();. The app then tells systemd when it has really started and stopped; according to the UseSystemd reference, that requires Type=notify in the unit. Add Type=notify only together with that call, or systemd will wait for a notification that never comes.
Start it:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
journalctl -u myapp -f
curl -I http://127.0.0.1:5000
Step 6: Put nginx in front
sudo apt-get install -y nginx
Create /etc/nginx/sites-available/myapp, adapted from Microsoft's example (the map block makes WebSockets, SignalR and Blazor Server work):
map $http_connection $connection_upgrade {
"~*Upgrade" $http_connection;
default keep-alive;
}
server {
listen 80;
server_name example.com www.example.com;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Always set an explicit server_name; Microsoft's guide flags a missing one as a security risk. Enable the site and test before reloading:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
Step 7: SSL and firewall
sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
Certbot edits the nginx config and sets up automatic renewal. Port 5000 stays closed to the internet. More detail in our Let's Encrypt guide.
How do you update the app?
Upload the new build to a new release folder, move the current symlink and restart:
sudo ln -sfn /srv/myapp/releases/20261021120000 /srv/myapp/current
sudo systemctl restart myapp
Rolling back is the same command with the previous folder. Two caveats: a restart has a short gap where nginx returns 502, and after runtime patches arrive through apt upgrade, Microsoft says to restart the apps. Gap-free releases need two instances and a health check before switching, which a deploy tool or panel does for you. Our post on Git auto-deploy covers deploying on every push.
How to deploy ASP.NET Core to Linux with ZoPanel
ZoPanel builds .NET apps from source on the server and runs them as a service of a hosting account. Based on the .NET docs:
- Install the SDK: an administrator opens Runtimes and installs .NET 10.0, 9.0 or 8.0 on the .NET SDK card. Versions sit side by side. On Ubuntu 24.04, 8.0 and 10.0 come from Ubuntu's packages and 9.0 from the backports repository.
- Create the website: Websites → New website → Git deploy, with the domain and repository. See Git deploy.
- Check detection: a
.csprojor.slnis detected as ASP.NET Core (projects up to two folders deep). The proposed commands aredotnet restore,dotnet publish 'MyApp.csproj' -c Release -o out --no-restore, andASPNETCORE_URLS=http://127.0.0.1:$PORT dotnet out/'MyApp.dll'. The SDK version follows<TargetFramework>, unless your repository has its ownglobal.json. - Add settings under Environment variables, for example
ConnectionStrings__Default. Values are kept in a root-only file and passed by systemd, never on the command line. - Deploy. The new release starts in the idle slot and only goes live once it answers the Health check path with a status below 500 within 90 seconds. Until then the old release keeps serving. Rollback keeps the last 5 releases.
You still add UseForwardedHeaders as in step 4, since nginx passes X-Forwarded-For and X-Forwarded-Proto. Each release is a new folder, so keep the Data Protection keys and uploads in a Persistent paths entry or in the database. Do not hard-code the port: PORT differs between the two blue/green slots.
What to know before choosing it: builds run on the server inside the account's RAM limit, so on small packages set DOTNET_gcServer=0 or DOTNET_GCHeapHardLimit as the docs describe. On Debian arm64 only .NET 10 is available, because Microsoft publishes .NET 8 and 9 packages for Debian on amd64 only. And ZoPanel is a Linux panel: it does not run IIS or .NET Framework 4.x apps.
Alternatives: Windows/IIS and Azure App Service
| Option | Good for | Trade-off |
|---|---|---|
| Linux VPS by hand | Full control, low cost, learning the stack | You own updates, releases and monitoring |
| Linux VPS with ZoPanel | Several apps or customers on one server, Git deploy, rollbacks | Linux only; builds use the server's RAM |
| Windows Server with IIS | .NET Framework apps, Windows Authentication, existing Windows shops | Windows Server licensing; IIS administration |
| Azure App Service | Managed platform with deployment slots and scaling | Cost grows with plan size; less control over the host |
IIS hosting with the ASP.NET Core Module is mature and fully supported, and the only option here for .NET Framework code. Azure App Service removes server maintenance and shares the Data Protection key ring across instances for you.
Common mistakes
- 502 Bad Gateway: the app is not running or listens elsewhere, often
localhost:5000from a default or a URL inappsettings.json. Checkjournalctl -u myappandss -ltnp. - Redirect loop or
http://links after enabling SSL:UseForwardedHeadersis missing or comes after other middleware. - Everyone is logged out after each deploy: Data Protection keys are in memory or inside the release folder.
The application to execute does not exist: the DLL name inExecStartis wrong, often because the project sets<AssemblyName>.
Conclusion
Deploying ASP.NET Core to Linux comes down to a supported runtime (.NET 10 from Ubuntu's packages), a published build, systemd, nginx with forwarded headers, a persistent key ring and SSL. Doing it by hand once teaches you every moving part. When you have several apps, or want health-checked releases and one-click rollback without writing scripts, try ZoPanel's Free plan: 3 hosting accounts, 10 websites and 10 databases with no time limit, and .NET apps with Git deploy are included. The same flow for other stacks is in our guides to Node.js and Python.
Frequently asked questions
Can ASP.NET Core run on Linux in production?
Yes. ASP.NET Core is cross-platform, and Microsoft documents and supports hosting it on Linux behind nginx with systemd. Kestrel serves the app, and nginx handles SSL, the domain and static file offloading.
Do I need the SDK on the server?
No, only if you build there. For a framework-dependent app, aspnetcore-runtime-10.0 is enough; for a self-contained app, not even that. Panels that build from Git, such as ZoPanel, install the SDK because they run dotnet publish on the server.
Should I use .NET 8 or .NET 10 for a new deployment?
.NET 10. Both .NET 8 and .NET 9 reach end of support on 10 November 2026, while .NET 10 LTS is supported until 14 November 2028. After the end-of-support date there are no more security patches.
Is Kestrel safe to expose directly to the internet?
Kestrel can serve traffic directly, but on a VPS it is simpler to keep it on 127.0.0.1 and let nginx handle SSL and several domains on ports 80 and 443. Certificate renewal then never touches your app.
Why do my users get logged out after every restart?
The Data Protection keys that encrypt authentication cookies are not persisted. Store them with PersistKeysToFileSystem in a folder outside the release directory, writable only by the app user, and give the app a fixed SetApplicationName.
Install ZoPanel free
One command on a fresh Ubuntu or Debian server. Up and running in minutes.
Install in 5 minutesKeep reading

Auto deploy from GitHub to a VPS: webhooks, Actions, zero downtime
Three ways to auto deploy from GitHub to a VPS: GitHub Actions over SSH, webhooks, or a panel with Git deploy, plus release scripts, health checks and rollback.

Deploy Laravel to a VPS: queues, scheduler and zero-downtime releases
How to deploy Laravel to a VPS on Ubuntu 24.04: PHP-FPM, nginx, .env, queue workers, the scheduler and zero-downtime releases, by hand or with ZoPanel.

Deploy Next.js to a VPS without Vercel
How to deploy Next.js to a VPS: standalone output, systemd, nginx caching for /_next/static, SSL, zero-downtime releases, and what you give up versus Vercel.