← All posts

How to deploy an ASP.NET Core app to a Hetzner VPS

Hetzner’s Cloud Servers are the cheapest real compute you can point an ASP.NET Core app at — the shared-vCPU CX line starts around $4.50/month. Nothing about running .NET on it is Hetzner-specific: the server is plain Ubuntu, and everything below is exactly the sequence a fresh CX22 needs, tested end to end on a real Ubuntu 24.04 box before writing it down.

1. Create the server and an SSH key

In the Hetzner Cloud console (or hcloud server create if you use their CLI), create a project, add an SSH key under Security → SSH Keys, and create a server: image Ubuntu 24.04, type CX22 (2 vCPU / 4 GB RAM / 40 GB disk), and the SSH key you just added. Hetzner emails you nothing — the key is how you get in, there’s no root password to remember.

ssh root@<server-ip>

2. Update the system and create a deploy user

Running the app as root is the first mistake most manual setups make. Create a dedicated user before anything else touches the box:

apt update && apt upgrade -y
adduser --disabled-password --gecos "" deploy
usermod -aG sudo deploy

3. Lock down the firewall

apt install -y ufw
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

ufw status should show exactly those three rules. Nothing else needs to be reachable from the internet — the app itself only ever listens on 127.0.0.1, behind nginx.

4. Turn on unattended security upgrades and fail2ban

apt install -y unattended-upgrades fail2ban
systemctl enable --now unattended-upgrades
systemctl enable --now fail2ban

systemctl is-enabled unattended-upgrades should print enabled; that’s the difference between a server that silently falls behind on kernel and OpenSSL patches and one that doesn’t. fail2ban watches /var/log/auth.log and temporarily bans IPs that fail SSH login repeatedly.

5. Install the ASP.NET Core runtime

Don’t add Microsoft’s apt repository for this — on a fresh Ubuntu 24.04 box the standalone install script is faster and doesn’t fight with distro packages. Install the runtime, not the SDK; the SDK is only needed if you’re building on the server itself, which the last section covers.

wget -q https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh
chmod +x dotnet-install.sh
./dotnet-install.sh --channel 9.0 --runtime aspnetcore --install-dir /usr/share/dotnet
ln -s /usr/share/dotnet/dotnet /usr/bin/dotnet
dotnet --list-runtimes

That last command should print Microsoft.AspNetCore.App 9.0.x and Microsoft.NETCore.App 9.0.x. Use --channel 8.0 instead if your project targets .NET 8.

6. Publish and copy the app

On your own machine, publish framework-dependent for linux-x64:

dotnet publish -c Release -r linux-x64 --self-contained false -o publish

On the server, releases live in timestamped folders with a current symlink pointing at whichever one is live — the same shape DotDeployer uses, and what makes rollback a symlink swap instead of a redeploy:

mkdir -p /var/www/myapp/releases/$(date +%Y%m%d%H%M%S)
mkdir -p /var/www/myapp/shared

Copy the contents of publish/ into that new releases folder (scp -r or rsync over SSH), then point current at it:

ln -sfn /var/www/myapp/releases/<timestamp> /var/www/myapp/current
chown -R deploy:deploy /var/www/myapp

7. Run it as a systemd service

This is the piece that turns “I started it in a terminal” into “it comes back after a reboot or a crash.” Create /etc/systemd/system/myapp.service:

[Unit]
Description=myapp
After=network.target

[Service]
Type=simple
WorkingDirectory=/var/www/myapp/current
ExecStart=/usr/bin/dotnet /var/www/myapp/current/MyApp.dll
Restart=always
RestartSec=5
KillSignal=SIGINT
SyslogIdentifier=myapp
User=deploy
EnvironmentFile=-/var/www/myapp/shared/.env
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=ASPNETCORE_URLS=http://127.0.0.1:5000

[Install]
WantedBy=multi-user.target

EnvironmentFile=-/var/www/myapp/shared/.env (the leading - means “don’t fail if it’s missing”) is where connection strings and API keys go — one KEY=value per line, readable only by deploy, never in source control. Then:

systemctl daemon-reload
systemctl enable --now myapp
systemctl status myapp
journalctl -u myapp -f

systemctl status should show active (running), and curl http://127.0.0.1:5000 from the server itself should return your app’s response. KillSignal=SIGINT matters specifically for ASP.NET Core: it’s what tells Kestrel to shut down gracefully instead of being killed mid-request.

8. Put nginx in front of it

Kestrel is not meant to face the internet directly — nginx terminates TLS, handles slow clients, and gives you a normal place to add caching or redirects later. Install it and write one site file:

apt install -y nginx

/etc/nginx/sites-available/myapp:

server {
    listen 80;
    server_name myapp.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 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;
    }
}

The last two proxy_set_header lines are the ones people skip and then wonder why HttpContext.Connection.RemoteIpAddress shows nginx’s own IP for every request, or why Request.IsHttps is always false behind TLS termination — add app.UseForwardedHeaders(...) in Program.cs reading those headers, or ASP.NET Core has no way to know the original request was HTTPS.

ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl restart nginx

nginx -t must print “syntax is ok” / “test is successful” before you restart — it catches typos before they take the site down.

9. HTTPS with Let’s Encrypt

Point myapp.example.com’s DNS A record at the server’s IP first — certbot can’t issue a certificate for a name that doesn’t resolve to this box yet.

apt install -y certbot python3-certbot-nginx
certbot --nginx -d myapp.example.com

certbot edits the nginx site file to add the listen 443 ssl block and redirect port 80 to 443, and installs a systemd timer (certbot.timer) that renews automatically well before the 90-day certificate expires — confirm it with systemctl list-timers | grep certbot.

What breaks (and why)

502 Bad Gateway. Either the app isn’t running (systemctl status myapp) or it’s listening on the wrong port/interface — ASPNETCORE_URLS has to match what nginx’s proxy_pass points at, and it must be 127.0.0.1, not localhost, if IPv6 is involved.

Permission denied reading the app’s files. chown -R deploy:deploy has to run after every copy into a new release folder, or the deploy user the systemd unit runs as can’t read its own binaries.

Environment variables not reaching the app. EnvironmentFile paths are absolute and the file needs to exist with the exact KEY=value syntax (no export, no quotes) — journalctl -u myapp will show the app failing at startup if a required variable is missing.

A reload that doesn’t pick up new code. Copying a new build into the same folder and restarting is fragile if a file is mid-write when systemd restarts it. The releases/symlink pattern above avoids this entirely: build into a new timestamped folder, repoint the symlink, then systemctl restart myapp — and roll back by repointing the symlink to the previous release and restarting again.

Restart on push, without writing a pipeline

The nine steps above are one-time. A minimal redeploy script run over SSH after a git pull is just steps 6 and 7 repeated:

#!/bin/bash
set -e
REL=/var/www/myapp/releases/$(date +%Y%m%d%H%M%S)
mkdir -p "$REL"
cp -r publish/. "$REL"/
ln -sfn "$REL" /var/www/myapp/current
systemctl restart myapp

That’s enough for a solo project. Or let DotDeployer do steps 3 through 9 — provisioning, hardening, the runtime, nginx, and certificates — and turn this script into a webhook that fires on every push, with the previous five releases kept on disk for a one-click rollback.

Deploy it with DotDeployer

Connect your Hetzner account, add a site from your GitHub repo, and DotDeployer provisions the CX22, hardens it, installs the runtime, and configures nginx and HTTPS automatically -- then redeploys on every push.

Deploy to Hetzner with DotDeployer · Getting started