How to Deploy .NET Applications on Linux for Production — Complete Security-First DevOps Guide (2026)

Introduction

Deploying .NET applications to a Linux production server requires far more than copying files and running dotnet run. A professional, enterprise-grade deployment on Ubuntu or Debian demands a carefully layered architecture: a hardened server, a non-root service account, a reverse proxy in front of Kestrel, secrets injected through environment variables, and HTTPS enforced end-to-end.

Most tutorials skip these fundamentals — running the app as root, hardcoding connection strings, and exposing Kestrel directly to the public internet. Every one of those shortcuts is a production security incident waiting to happen. This guide covers the correct, security-first approach used in real enterprise .NET deployments on Linux in 2026.

To follow along with the sample application used in this guide, you can check this source code on GitHub.


The Architecture of a Production .NET Deployment on Linux

Before running a single command, understand the request flow. A correctly deployed .NET application on Linux is never exposed directly to the public internet. Kestrel — the built-in .NET web server — is an internal process listener, not a hardened public-facing server. A production stack uses three components layered together:

  • Nginx: The public-facing reverse proxy. Handles HTTPS termination, TLS certificates, HTTP header injection, static file caching, and rate limiting. Shields Kestrel from direct exposure to the internet.
  • Kestrel: The high-performance internal web server embedded in every ASP.NET Core application. Listens only on localhost — never on a public IP in production.
  • Systemd: The Linux init system that manages your .NET application as a background daemon — automatically restarting it on crash, on server reboot, and on unhandled exceptions.

Traffic flows: Internet → Nginx (port 443/HTTPS) → Kestrel (localhost:5000) → .NET Application. This separation is non-negotiable in any production Linux deployment.


Prerequisites: Preparing and Hardening the Linux Server for .NET

Before proceeding with any deployment step, your Linux server must be hardened and Nginx must be correctly installed. This includes configuring UFW firewall rules, disabling root SSH login, and enabling automatic security updates. We strongly recommend following our Server Hardening and Nginx Installation Guide before continuing.

Once your server is hardened, confirm that the .NET SDK and runtime are installed and visible:

dotnet --list-sdks
dotnet --list-runtimes

If .NET is not yet installed, follow our complete Multi-SDK Installation Guide for Linux before continuing with this guide.


Step 1: Secure Directory Structure and File Permission Strategy for .NET on Linux

Professional Linux administrators do not deploy application binaries into /var/www/html — that directory is for static web content served directly by Nginx, not for compiled application logic. Your .NET application binaries, configuration files, and logs belong in a purpose-built directory with strict, non-root ownership.

Choose your deployment strategy based on your workflow:

Option A: Manual Deployment (Development and Learning)

A dual-folder structure separates source code from compiled publish output. This prevents .git metadata, developer configuration files, and build artifacts from ever being accessible in the directory served by your application process.

# Create the dual-folder structure under the home directory
sudo mkdir -p /home/$USER/webapps/DemoApp/source
sudo mkdir -p /home/$USER/webapps/DemoApp/publish

Option B: CI/CD Automated Deployment (Production Standard)

In a professional CI/CD pipeline using GitHub Actions, GitLab CI, or Jenkins, the build and publish step runs on the pipeline agent. Only the final compiled binaries are transferred to the production server — no source code ever touches the live environment.

# Create the single production binaries directory for CI/CD pipelines
sudo mkdir -p /home/$USER/webapps/DemoApp.com

Regardless of which deployment strategy you use, always assign directory ownership to a non-root system user and set correct read/execute permissions:

sudo chown -R $USER:$USER /home/$USER/webapps/DemoApp
sudo chmod -R 755 /home/$USER/webapps/DemoApp

Step 2: Clone the Source Code and Publish the .NET Application for Linux

For manual deployments, navigate into the source directory, clone the repository, and run dotnet publish targeting the Linux x64 runtime with a framework-dependent (non-self-contained) output — the smallest and most maintainable production artifact:

cd /home/$USER/webapps/DemoApp/source
git clone https://github.com/SaraRasoulian/DotNet-WebAPI-Sample.git .
dotnet publish -c Release -o /home/$USER/webapps/DemoApp/publish --runtime linux-x64 --self-contained false

How to Fix the MSB3202 "Project File Not Found" Error in dotnet publish

When publishing repositories that contain multiple projects or nested solution structures, a very common error is MSB3202: The project file was not found. This occurs when the .csproj file lives inside a subdirectory and dotnet publish cannot locate it from the repository root:

The fix is straightforward — pass the explicit path to the .csproj file directly to dotnet publish:

# Explicitly target the nested project file to resolve MSB3202
dotnet publish src/WebAPI/WebAPI.csproj -c Release -o /home/$USER/webapps/DemoApp/publish --runtime linux-x64 --self-contained false

Step 3: Run Your .NET Application as a Background Systemd Service on Linux

Systemd is the standard Linux process manager for running production services. It starts your .NET application automatically at server boot, restarts it after crashes or unhandled exceptions, and integrates it with the system journal for centralized log access via journalctl.

Create a dedicated systemd unit file for your application:

sudo nano /etc/systemd/system/DemoApp.service

Paste the following complete, production-ready service configuration — replacing YOUR_USERNAME with your actual non-root deployment user:

[Unit]
Description=.NET Web API Production Service — DemoApp
After=network.target

[Service]
# Point WorkingDirectory to your publish folder (Option A) or CI/CD folder (Option B)
WorkingDirectory=/home/YOUR_USERNAME/webapps/DemoApp/publish
ExecStart=/usr/bin/dotnet /home/YOUR_USERNAME/webapps/DemoApp/publish/WebAPI.dll
Restart=always
RestartSec=10
KillSignal=SIGINT
SyslogIdentifier=dotnet-demo-app
User=YOUR_USERNAME

# ASP.NET Core environment and internal listener configuration
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=ASPNETCORE_URLS=http://localhost:5000

[Install]
WantedBy=multi-user.target

Enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable DemoApp
sudo systemctl start DemoApp
sudo systemctl status DemoApp

Step 4: Securing Production Secrets — appsettings.json and Environment Variables on Linux

Hardcoding database connection strings, API keys, or JWT secrets inside appsettings.json is one of the most critical and common production security mistakes in .NET deployments. If your source code repository is ever compromised, leaked, or accidentally made public, every hardcoded credential is immediately exposed.

The correct production pattern is a two-layer approach: appsettings.json contains only non-sensitive configuration defaults, and all real secrets are injected at runtime through Linux environment variables defined in the Systemd service file.

1. The Production appsettings.json — Non-Sensitive Defaults Only

Your deployed appsettings.json in the publish directory should contain safe defaults and logging configuration only — never real credentials:

{
  "ConnectionStrings": {
    "Database": "Host=localhost;Port=5433;Database=CustomerLoyaltyDB;Username=postgres;Password=mysecretpassword;",
    "Redis": "localhost:6379"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "Serilog": {
    "Using": [
      "Serilog.Sinks.File"
    ],
    "MinimumLevel": {
      "Default": "Information"
    },
    "WriteTo": [
      {
        "Name": "File",
        "Args": {
          "path": "../logs/webapi-.log",
          "rollingInterval": "Day",
          "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {CorrelationId} {Level:u3} {Username} {Message:lj}{Exception}{NewLine}"
        }
      },
      {
        "Name": "Console",
        "Args": {
          "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss} [{Level:u3}] {Message}{NewLine}{Exception}"
        }
      }
    ]
  },
  "JwtSettings": {
    "Issuer": "http://localhost:5000/",
    "Audience": "http://localhost:5000/",
    "Key": "This is a sample secret key - please don't use in production environment"
  },
  "AllowedHosts": "*"
}

2. Injecting Real Secrets via Systemd Environment Variables

ASP.NET Core's configuration system automatically reads Linux environment variables and maps them to your application's configuration hierarchy. Variables using double underscores (__) as a delimiter map directly to nested JSON keys — so ConnectionStrings__DefaultConnection overrides ConnectionStrings.DefaultConnection in your JSON config at runtime.

Open your service file and add secrets under the [Service] section:

sudo nano /etc/systemd/system/DemoApp.service
[Service]
# ... existing configuration above ...

# Production database connection string — injected at runtime, never in source code
Environment=ConnectionStrings__DefaultConnection="Server=127.0.0.1;Database=RealProdDb;Port=3306;User Id=db_admin;Password=YourExtremelyComplexPassword123!;"

# External service API key
Environment=ExternalServices__ApiKey="prod-live-key-abc-123"

# JWT signing secret — must be high entropy in production
Environment=JWT__SecretKey="high-entropy-random-string-here"

3. Apply the Updated Secrets Configuration

After editing the service file, always reload the systemd daemon and restart the service so the new environment variables take effect:

sudo systemctl daemon-reload
sudo systemctl restart DemoApp

Step 5: Configure Nginx as a Reverse Proxy for .NET Kestrel on Linux

Nginx sits between the public internet and your .NET Kestrel process. It terminates HTTPS, strips and injects security headers, and forwards clean HTTP requests to your internal application listener on localhost:5000. Never expose Kestrel directly on a public port in a production environment.

Create a dedicated Nginx site configuration file for your application:

sudo nano /etc/nginx/sites-available/DemoApp.conf

Paste the following production-ready reverse proxy configuration:

server {
    listen 80;
    server_name demoapi.devopsfix.com;

    location / {
        proxy_pass http://localhost:5000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection keep-alive;
        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;
    }
}

Enable the site by creating a symbolic link to the active sites directory, then validate and reload Nginx:

# Enable the site configuration
sudo ln -s /etc/nginx/sites-available/DemoApp.conf /etc/nginx/sites-enabled/

# Validate the Nginx configuration syntax before reloading
sudo nginx -t

# Reload Nginx to apply the new reverse proxy configuration
sudo systemctl restart nginx
sudo systemctl status nginx

Step 6: Enforce HTTPS with Let's Encrypt and Certbot on Linux

In 2026, serving a production .NET API over plain HTTP is not acceptable. Browsers flag it, search engines penalize it, and any data transmitted is unencrypted in transit. Let's Encrypt provides free, automatically renewing TLS certificates, and Certbot handles the full installation and Nginx integration automatically.

Critical prerequisite before running Certbot: Let's Encrypt uses an HTTP-01 or DNS-01 challenge to verify that you control the domain. Your server must be publicly reachable from the internet with a valid public DNS A record pointing to your server's IP address. If you are working on a local machine, a private network, or a VPS without a domain pointed at it, Let's Encrypt certificate issuance will fail. In those cases, use a self-signed certificate for internal testing instead.

# Install Certbot and the official Nginx plugin
sudo apt install certbot python3-certbot-nginx -y

# Issue and automatically install the SSL certificate for your domain
sudo certbot --nginx -d demoapi.devopsfix.com

Certbot automatically modifies your Nginx configuration to redirect HTTP to HTTPS and installs the certificate. Renewal is handled automatically by a systemd timer — no manual intervention required.


Expert DevOps Best Practices for .NET on Linux Production Servers

Management Task Standard Production Procedure DevOps Benefit
Security Patching Enable unattended-upgrades for automatic OS security updates Closes known CVEs automatically without manual intervention or maintenance windows
Application Logging Use journalctl -u DemoApp -f for live log streaming Centralized structured log access — no manual log file hunting across the filesystem
Process Privileges Always run .NET services as a named non-root $USER account Limits blast radius of any exploit — a compromised process cannot modify system files
Secret Management Inject all credentials via Systemd Environment= directives, never in source code Secrets are never committed to version control or visible in application binaries
Certificate Renewal Let Certbot's systemd timer handle automatic Let's Encrypt renewal Eliminates manual renewal risk — certificates never expire unexpectedly in production

Troubleshooting Common .NET on Linux Production Errors

The following errors are the most frequently encountered in production .NET Linux deployments. Each has a known, deterministic cause and fix:

  • 502 Bad Gateway (Nginx): Nginx is running and accepting connections, but cannot reach your .NET Kestrel process on localhost:5000. The application has crashed, failed to start, or is listening on the wrong port. Check the service status with sudo systemctl status DemoApp and review logs with journalctl -u DemoApp -f. For a detailed walkthrough, see our guide on Fixing 502 Bad Gateway Nginx Errors for .NET on Linux.
  • 403 Forbidden: The application process does not have read or execute permission on its own working directory or binary files. Verify ownership with ls -la /home/$USER/webapps/ and reapply permissions with sudo chown -R $USER:$USER and chmod -R 755.
  • Application Crashes on Startup: Startup failures are almost always caused by missing environment variables, an incorrect connection string, or a misconfigured appsettings.json. Stream live logs immediately after a restart: journalctl -u DemoApp -f.
  • MSB3202 Project File Not Found: The dotnet publish command cannot locate the .csproj file. Pass the explicit relative or absolute path to the project file as shown in Step 2 above.

Frequently Asked Questions — Deploying .NET on Linux

Why use Nginx in front of Kestrel instead of exposing Kestrel directly?

Kestrel is a high-performance application server, not a hardened public-facing web server. Nginx provides TLS termination, connection rate limiting, request header security, HTTP/2 support, and static file serving — all capabilities that Kestrel alone is not designed to handle reliably in a hostile internet-facing environment. The Nginx-in-front-of-Kestrel pattern is the official Microsoft-recommended production topology for ASP.NET Core on Linux.

What is the correct way to manage production secrets in a .NET Linux deployment?

As detailed in Step 4, the correct approach is injecting all sensitive credentials as Linux environment variables via the Systemd service file using the Environment= directive. ASP.NET Core's built-in configuration system automatically maps these variables to your application's IConfiguration hierarchy at startup — overriding any matching keys in appsettings.json — without ever requiring secrets to appear in your source code or version control history.

Can I deploy multiple .NET applications on the same Linux server?

Yes. Each application gets its own Systemd service unit file, its own isolated publish directory, and its own internal port (5000, 5001, 5002, and so on). Nginx routes external domain traffic to the correct internal port based on the server_name directive in each site configuration file. This is the standard pattern for running multiple ASP.NET Core microservices on a single Linux server.

Does this deployment guide work on Ubuntu 22.04, Ubuntu 24.04, and Debian 12?

Yes. All commands in this guide are tested and compatible with Ubuntu 20.04 LTS, Ubuntu 22.04 LTS, Ubuntu 24.04 LTS, and Debian 11 and 12. The Nginx configuration syntax, Systemd unit file format, Certbot installation commands, and directory structure are identical across all these distributions.


Conclusion: Following this guide gives you a production .NET Linux deployment that is secure by default, crash-resilient through Systemd, HTTPS-enforced through Certbot, and maintainable through clean secret management and centralized logging. This is the professional standard for running ASP.NET Core applications on Linux in 2026.

f X W