How to Secure an ASP.NET Core Deployment on Linux (Nginx & Systemd)

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.

Diagram showing a secure .NET Core deployment on Linux with Nginx, TLS, and systemd sandboxing

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.dll on 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 nano or vim.

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:

csharp
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:

nginx
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:

csharp
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):

nginx
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.

bash
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:

bash
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:

ini
[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:

bash
dotnet add package Microsoft.Extensions.Hosting.Systemd

Then register it in Program.cs:

csharp
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:

bash
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:

bash
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:

bash
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.

Codingvila provides articles and blogs on web and software development for beginners as well as free Academic projects for final year students in Asp.Net, MVC, C#, Vb.Net, SQL Server, Angular Js, Android, PHP, Java, Python, Desktop Software Application and etc.

If you have any questions, contact us on info.codingvila@gmail.com