September 12, 2026
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.