If you want to secure a .NET Core deployment on Linux, the short answer is this: never expose Kestrel directly to the internet, put a reverse proxy in front of it, terminate TLS (Transport Layer Security, the protocol that encrypts traffic between client and server) properly, and run the app under a locked-down systemd service instead of a permissive root process. That combination closes most of the gaps that catch teams off guard after their first successful deployment.
Getting an ASP.NET Core app running on a Linux VM is the easy part these days
— dotnet publish, copy the files, run the
binary, done. The part nobody warns you about is that a working deployment and
a secure one are not the same thing. Kestrel, the cross-platform web server
built into ASP.NET Core, was never designed to be your app's only line of
defense against the open internet. It's a fast, minimal server meant to sit
behind something sturdier. This guide walks through the four layers that
matter most: hardening Kestrel itself, putting Nginx in front of it as a
reverse proxy (a server that forwards client requests to your app and returns
the response, hiding the app server from direct exposure), setting up TLS
certificates correctly, and sandboxing the whole thing with systemd so a
compromised process can't do much damage even if something does go wrong.
Prerequisites
Before working through this guide, you should have:
-
A .NET 8 or .NET 9 application already publishing successfully and running
with
dotnet yourapp.dllon a Linux server (Ubuntu, Debian, or similar). - Root or sudo access on that server.
- Nginx installed from your distro's package manager or the official Nginx repositories (mainline or stable branch).
- A registered domain name pointing at the server's public IP address, since TLS certificates from a public certificate authority require domain validation.
-
Basic comfort with the Linux command line and editing configuration files
with a text editor like
nanoorvim.
You do not need deep systemd or Nginx expertise going in — this guide explains each piece as it comes up.
Step 1: Bind Kestrel to Localhost Only
By default, a naive deployment lets Kestrel listen on all network interfaces,
which means it's reachable directly from the internet on whatever port you
configured. That's the first thing to fix. Kestrel should only ever listen on
localhost (127.0.0.1), with Nginx handling
all external traffic and forwarding requests internally.
In Program.cs, configure Kestrel explicitly
rather than relying on defaults:
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(options =>
{
// Bind only to loopback — Nginx will proxy requests here
options.Listen(System.Net.IPAddress.Loopback, 5000);
// Limit request body size to reduce abuse from oversized payloads
options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB
// Guard against slow-client attacks by capping how long headers can take
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(10);
// Limit concurrent connections per app instance
options.Limits.MaxConcurrentConnections = 200;
options.Limits.MaxConcurrentUpgradedConnections = 100;
// Hide the "Server: Kestrel" header from responses
options.AddServerHeader = false;
});
var app = builder.Build();
This does a few things worth calling out individually.
IPAddress.Loopback means the app is
unreachable from outside the box, full stop, even if a firewall rule is later
misconfigured. The body size and header timeout limits reduce the blast radius
of denial-of-service attempts that try to exhaust memory or hold connections
open. Turning off the Server response header
is a small thing, but there's no reason to advertise exactly what server
software and potentially what version you're running to anyone probing your
endpoints.
It's also worth double-checking that
appsettings.json or environment variables
aren't overriding this with a
Kestrel:Endpoints section bound to
0.0.0.0. Configuration precedence in ASP.NET
Core means a later-loaded source can silently undo what you just set in code,
so it pays to check both places.
Step 2: Configure Nginx as a Reverse Proxy
With Kestrel locked to loopback, Nginx becomes the only thing the outside world talks to. It handles TLS termination, adds a layer of request filtering, and forwards legitimate traffic internally to Kestrel over plain HTTP on localhost — which is fine, since that traffic never leaves the machine.
A reasonably hardened site configuration looks like this:
server {
listen 80;
server_name example.com www.example.com;
# Redirect all plain HTTP traffic to HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Prefer TLS 1.2 and 1.3, disable older protocols
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
# Basic security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
client_max_body_size 10M;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
Note that the combined
listen 443 ssl http2; syntax shown in older
configs was deprecated starting with nginx 1.25.1 in favor of the separate
http2 on; directive used above; if you're
running an nginx version older than 1.25.1, use the combined
listen 443 ssl http2; form instead.
A few details matter here beyond the obvious redirect-to-HTTPS logic. The
X-Forwarded-* headers tell your ASP.NET Core
app what the original request looked like — the real client IP, the original
protocol — since from Kestrel's point of view every request now comes from
Nginx on localhost over plain HTTP. Without forwarding these headers
correctly, your app's logging, rate limiting, and any logic checking
Request.IsHttps will misbehave.
On the ASP.NET Core side, you need to tell the app to trust and read those
forwarded headers, since it won't do so automatically. This requires the
Microsoft.AspNetCore.HttpOverrides namespace,
which isn't part of the default global usings, so add it explicitly at the top
of Program.cs:
using Microsoft.AspNetCore.HttpOverrides;
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
Add this near the top of your middleware pipeline, before authentication or
authorization middleware, or those components will see the wrong scheme and
client address. Also worth noting:
ForwardedHeadersOptions by default only
trusts proxies on the local network — if Nginx runs on the same machine as
Kestrel, that default usually works fine, but if you introduce a load balancer
in front of Nginx later, you'll need to explicitly configure
KnownProxies or
KnownNetworks.
The client_max_body_size in Nginx should
match or slightly exceed the
MaxRequestBodySize set in Kestrel —
mismatched limits are a common source of confusing "why did my upload fail
silently" bugs.
One small refinement worth knowing about: the sample config sends
Connection: upgrade to Kestrel on every
request, not just WebSocket upgrade requests. That's a common pattern and it
works, but it's not strictly correct — for ordinary HTTP requests it's more
precise to let Nginx choose the header based on whether the client actually
sent an Upgrade header, using a
map directive at the top of the config
(outside the server blocks):
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
Then reference $connection_upgrade instead of
the hardcoded "upgrade" string:
proxy_set_header Connection $connection_upgrade;. This is an optional refinement, not a correctness bug in the config above.
Step 3: Obtain and Automate TLS Certificates
Certbot, the Let's Encrypt client, is the most common way to get free,
automatically renewing TLS certificates for a Linux-hosted site. The exact
package name varies slightly by distro, but on Debian/Ubuntu-based systems
it's typically installed via apt alongside an
Nginx plugin that edits your config automatically.
sudo apt update
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
Certbot will detect your existing Nginx server blocks, obtain a certificate, and update the configuration to reference the new certificate files, similar to what's shown in the config above. It also installs a scheduled renewal job (typically a systemd timer or cron entry depending on your distro), since Let's Encrypt certificates are valid for 90 days and need automatic renewal well before that.
It's worth manually confirming renewal actually works rather than assuming it does:
sudo certbot renew --dry-run
A few TLS-specific practices worth following regardless of certificate authority: stick to TLS 1.2 and TLS 1.3 only, since TLS 1.0 and 1.1 are considered deprecated and are routinely flagged by security scanners and PCI compliance checks. Avoid self-signed certificates in production — they're fine for local development, but browsers and API clients will reject or warn on them in ways that erode user trust. If your app has multiple subdomains, a wildcard certificate can simplify renewal, though Let's Encrypt requires DNS-based validation rather than the simpler HTTP validation shown above for wildcard certs.
Step 4: Sandbox the App with systemd
Running your app as a systemd service instead of a background
dotnet run process gives you automatic
restarts, proper logging through journald,
and — this is the part most tutorials skip — a set of sandboxing directives
that restrict what the process can actually do on the system, even if it's
compromised.
A minimal, non-hardened service file might just specify the executable and working directory. A hardened one looks considerably more deliberate:
[Unit]
Description=Example ASP.NET Core App
After=network.target
[Service]
Type=notify
WorkingDirectory=/var/www/example
ExecStart=/usr/bin/dotnet /var/www/example/Example.dll
Restart=on-failure
RestartSec=5
# Run as a dedicated non-root, non-login user
User=exampleapp
Group=exampleapp
# Environment
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
# --- Sandboxing directives ---
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/www/example/logs
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictRealtime=true
MemoryDenyWriteExecute=true
CapabilityBoundingSet=
[Install]
WantedBy=multi-user.target
Type=notify is a deliberate choice here — it
lets systemd track when the app is actually ready to serve traffic rather than
just assuming it's up the moment the process starts, and it enables systemd's
watchdog features if you configure them later. But it only works if the
application actually sends the readiness signal, and out of the box, ASP.NET
Core doesn't. Without the integration below, systemd will wait roughly 90
seconds for a signal that never arrives, then mark the service failed and kill
it — even though the app itself started and is running fine.
To make Type=notify work, add the systemd
integration package to the project:
dotnet add package Microsoft.Extensions.Hosting.Systemd
Then register it in Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSystemd();
UseSystemd() detects at runtime whether the
process is actually running under systemd, so it's safe to leave in even when
you're running the app locally with
dotnet run — it's a no-op outside of a
systemd context. If you'd rather skip the extra dependency, the simpler
alternative is to change Type=notify to
Type=simple (or
Type=exec) in the unit file; you lose the
readiness/watchdog signaling, but the service will start and stay running
without any code changes.
Creating the dedicated non-root user before applying this unit file is a required step, not optional polish:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin exampleapp
sudo chown -R exampleapp:exampleapp /var/www/example
After saving the service file to
/etc/systemd/system/example-app.service,
reload systemd and enable the service:
sudo systemctl daemon-reload
sudo systemctl enable --now example-app.service
sudo systemctl status example-app.service
Each of the sandboxing directives closes off a category of damage a
compromised process could otherwise do.
NoNewPrivileges=true stops the process from
gaining more privileges than it started with, even via setuid binaries.
ProtectSystem=strict mounts most of the
filesystem read-only for this process, with
ReadWritePaths carving out the specific
directories — like a logs folder — that genuinely need write access.
ProtectHome=true blocks access to
/home,
/root, and
/run/user entirely, which a web app has no
legitimate reason to touch.
PrivateTmp=true gives the process its own
isolated /tmp, so it can't see or interfere
with temp files from other processes on the box.
MemoryDenyWriteExecute=true blocks a class of
exploit techniques that rely on writing executable code into memory at runtime
— but this directive is a well-known source of crashes or startup failures
with .NET applications specifically, since the CLR's JIT compiler needs to
allocate memory pages that are both writable and executable at various points,
and this directive blocks that via seccomp filtering on mmap/mprotect. Whether
it works depends on your .NET runtime version and configuration, so test it
carefully in a staging environment before relying on it in production. If you
hit unexplained crashes after adding it, that directive is the first thing to
test by removing temporarily.
CapabilityBoundingSet= with nothing after the
equals sign strips all Linux capabilities from the process — since a web app
doesn't need to bind to privileged ports (Nginx does that, not Kestrel) or
perform raw networking, there's no reason it should retain any of them.
How It Works
The reasoning behind this layered approach is defense in depth: no single layer is assumed to be perfect, so each one limits what a failure in another layer can do. Kestrel bound to loopback means a firewall misconfiguration or a bug in Nginx's proxy config can't expose the raw app server, since it's simply unreachable from outside the machine at the network level.
Nginx handling TLS termination and acting as the public-facing server means your app code doesn't need to manage certificates or worry about protocol-level attacks aimed at HTTP parsing — Nginx has had years of production hardening against exactly those.
The systemd sandboxing directives work at the kernel level, using Linux namespaces and mount restrictions rather than relying on the app's own code to behave well. Even if an attacker found a remote code execution bug in your application logic or a vulnerable NuGet package, the process they'd be running inside has no write access to most of the filesystem, no elevated capabilities, and no visibility into other users' files. That's a meaningfully smaller blast radius than a process running as root with full filesystem access, which is unfortunately still how a lot of quick-and-dirty deployments get set up.
Common Errors and Troubleshooting
502 Bad Gateway from Nginx after switching Kestrel to
loopback-only.
This almost always means Nginx's
proxy_pass port doesn't match what Kestrel is
actually listening on, or the app crashed on startup and isn't listening at
all. Check
sudo journalctl -u example-app.service -n 50
to see the app's actual startup logs before assuming it's a proxy config
issue.
systemd reports the service as failed roughly 90 seconds after starting,
even though the app logs show it running fine.
This is the Type=notify readiness-signal
issue described above — the unit file expects an
sd_notify call the app never sends. Add the
Microsoft.Extensions.Hosting.Systemd package
and builder.Host.UseSystemd();, or switch
Type=notify to
Type=simple.
Forwarded headers not being applied, so `Request.Scheme` shows `http` even
over HTTPS.
This usually means UseForwardedHeaders was
registered too late in the middleware pipeline, or the
ForwardedHeadersOptions.KnownProxies/KnownNetworks
settings don't include the Nginx instance's address, causing ASP.NET Core to
silently ignore the forwarded headers as an untrusted source.
systemd service fails to start with a permissions error after adding
`ProtectSystem=strict`.
This typically means the app is trying to write somewhere outside of what's
listed in ReadWritePaths — commonly a logs
directory, a SQLite database file, or a data protection key ring location.
Check the exact path from the error in
journalctl and add it explicitly to
ReadWritePaths, rather than loosening
ProtectSystem back to
false.
Certbot renewal fails silently months later, and the certificate
expires.
This is often caused by Nginx configuration changes made after the initial
Certbot run that break the automated renewal hook, or a firewall rule added
later that blocks the HTTP validation challenge on port 80. Running
certbot renew --dry-run periodically, or at
least after any Nginx config change, catches this before it becomes an outage.
Best Practices
A few habits make all of this easier to maintain over time rather than being a one-time hardening exercise you never revisit. Keep the reverse proxy, TLS termination, and application logic conceptually separate even when they run on the same box — resist the temptation to have your ASP.NET Core app handle certificates or listen on public ports "just this once" for convenience. Rotate and review the systemd sandboxing directives periodically, since newer systemd versions occasionally add finer-grained restrictions worth adopting; check your distro's systemd version and the upstream release notes rather than assuming your unit file from two years ago is still best practice.
Log aggressively but keep secrets out of logs — connection strings, API keys, and tokens should live in environment variables or a secrets manager, never hardcoded into appsettings.json files that get deployed to the server. Set up a firewall (ufw or iptables/nftables directly) as an additional layer even with Kestrel bound to loopback, restricting inbound traffic to only ports 80 and 443 plus SSH. If you are on Ubuntu or Debian, you can configure UFW to allow only these services with a few commands:
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
Crucial warning: Always allow OpenSSH (or your custom SSH port) before running ufw enable, otherwise you will lock yourself out of your remote server. The 'Nginx Full' profile automatically opens both port 80 (HTTP) and 443 (HTTPS).
Finally, treat this hardening pass as a baseline, not a finish line: run periodic vulnerability scans against your public-facing endpoints, keep the .NET runtime and Nginx package updated through your distro's security channel, and review the systemd unit file whenever you change what the app needs access to.
Conclusion
Securing a .NET Core deployment on Linux comes down to layering: Kestrel bound to localhost so it's never directly exposed, Nginx handling TLS termination and acting as the hardened public entry point, certificates that renew automatically instead of expiring silently, and a systemd service locked down with sandboxing directives so a compromise in one layer doesn't cascade into full system access.
None of these steps are exotic — they're mostly configuration discipline applied consistently rather than clever tricks. With this setup in place, you've moved from "the app happens to work" to a deployment that reflects how production ASP.NET Core services on Linux are actually expected to run. A reasonable next step is setting up automated log monitoring or a tool like Fail2ban to react to the suspicious traffic patterns your Nginx access logs will now start surfacing.
