← All posts

Zero-downtime deployments for ASP.NET Core: releases, symlinks and a health check

The naive version of “deploy my app” is: stop the service, copy the new build over the old one, start the service again. It works, until the copy is half-finished when something reads a file, or the new build is broken and now there’s no app running at all while you scramble to fix it. The releases-and-symlink pattern fixes both problems with nothing more exotic than mkdir, cp and ln -sfn — it’s the exact mechanism DotDeployer’s deploy and rollback jobs run (DeploymentPlan.cs and SshSiteDeployer.cs in the DotDeployer codebase), tested here against a real ASP.NET Core app on a real Ubuntu 24.04 box.

The layout

/var/www/myapp/
├── current -> releases/20260910143200/   (symlink)
├── releases/
│   ├── 20260910120000/
│   ├── 20260910134500/
│   └── 20260910143200/
└── shared/
    └── .env

Every deploy gets its own timestamped folder under releases/. current is a symlink, never a real directory, and the systemd unit’s WorkingDirectory and ExecStart both point through it (/var/www/myapp/current/MyApp.dll), not at a specific release. shared/ holds the one thing that should survive every deploy unchanged — environment variables, in a .env file the systemd unit reads with EnvironmentFile.

1. Build into a new release folder, not over the old one

mkdir -p /var/www/myapp/releases/20260912143000
cp -r publish/. /var/www/myapp/releases/20260912143000/
chown -R deploy:deploy /var/www/myapp/releases/20260912143000

The old release is still sitting there, completely untouched, while this happens. If the copy fails halfway through — disk fills up, connection drops — current still points at the last good release and the app never noticed anything happened.

ln -sfn /var/www/myapp/releases/20260912143000 /var/www/myapp/current
systemctl restart myapp.service

ln -sfn replaces the symlink target atomically — there’s no moment where current points at nothing or at a half-written path. The restart is the only part of this that’s actually visible to a request in flight, and it’s a few hundred milliseconds for a small ASP.NET Core app, not the minutes a full redeploy-from-scratch takes.

Shipping the build from your own machine

Everything above assumes the published output is already on the server. If you build locally (or in CI) and ship over SSH instead of building on the box, the release folder still has to exist before anything can be copied into it — mkdir -p can’t create a remote path whose parent doesn’t exist yet, so create the release directory first, then upload:

ssh deploy@<server-ip> "mkdir -p /var/www/myapp/releases/20260912143000"
rsync -az publish/ deploy@<server-ip>:/var/www/myapp/releases/20260912143000/

rsync (or scp -r, or SFTP if your tooling only speaks that) into the new folder, never into current directly — current only ever changes by symlink swap, in the next step, once every file has actually arrived.

Not every site type restarts

An ASP.NET Core app or a background worker needs systemctl restart after the symlink moves, because there’s a running process serving the old code that has to pick up the new build. A Blazor WebAssembly site is just static files served by nginx — there’s no process to restart, and skipping that step for a static site isn’t an oversight, it’s correct: the symlink swap alone is the entire deploy, and the next request nginx serves reads through the new current target automatically.

3. Health check before you call it done

code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/health)
[ "$code" -ge 200 ] && [ "$code" -lt 300 ]

This is the step that turns “I restarted the service” into “I confirmed the new release actually works.” Tested against a real app: deploy a build that starts and returns 200 from /health, and the check passes in under two seconds after the restart. Deploy a build that throws on startup, and the check fails immediately — which is exactly when you want to know, not five minutes later when a user reports it.

4. Keep five releases, prune the rest

cd /var/www/myapp/releases && ls -1t | tail -n +6 | xargs -r rm -rf --

ls -1t lists releases newest-first; tail -n +6 selects everything past the 5th; xargs -r does nothing if that list is empty (the -r matters — without it, rm -rf -- with no arguments still runs, harmlessly, but -r skips the fork entirely on a clean release history). Run this after the health check passes, never before — pruning first would delete the release a rollback might need if the new one turns out to be unhealthy after all.

ln -sfn /var/www/myapp/releases/20260910134500 /var/www/myapp/current
systemctl restart myapp.service

Then the same health check, then the same prune. No rebuild, no re-clone, no git checkout of an old commit — the previous release’s files are already sitting on disk in releases/, because step 4 keeps the last five. Rollback is exactly as fast as a deploy, because it’s the identical two commands with the target reversed.

Database migrations don’t get the same trick

A symlink swap is instant because both releases’ files already exist — a database schema doesn’t work that way; there’s one schema, shared by whichever release happens to be running during the few hundred milliseconds around a restart. Run migrations before the symlink swap (against the still-live old release), and keep every migration backward compatible with the previous release’s code for at least one deploy: add a new column instead of renaming one, keep a dropped column around for one release cycle before actually removing it. Get this wrong and a rollback — reverting the symlink to old code — can point old code at a schema shape it was never written to read.

Verified end to end

Tested against a real published ASP.NET Core app under systemd on Ubuntu 24.04: a v1 release serving /health with 200, a v2 release deployed with the symlink swapped and the service restarted (health check 200, new version served within two seconds), a sixth release added to confirm pruning removes exactly the oldest one and keeps five, and a rollback to v1 with the same symlink-swap-restart-health-check sequence, ending with v1 served again and its /health endpoint still returning 200.

What breaks (and why)

A deploy “succeeds” but serves the old version. The symlink didn’t actually move — double-check the ln -sfn target is the new release path, not a stale variable holding the previous one. readlink -f /var/www/myapp/current should always match the release you just deployed.

Health check passes locally but the deploy still feels broken. Check what the health check is actually hitting — 127.0.0.1 directly hits Kestrel, bypassing nginx. If nginx itself is misconfigured (wrong proxy_pass port, a stale sites-enabled symlink), the app can be perfectly healthy and still unreachable from outside.

Rollback “fails” because the old release is gone. This is what step 4’s keep 5, don’t keep 1 rule is for — if you’ve pruned down to a single release, there’s nothing left to roll back to. Five is enough headroom for “the last deploy was bad” without keeping unbounded history on disk.

Deploy it with DotDeployer

DotDeployer runs exactly this sequence on every push -- release folder, symlink swap, health check, prune to five, one-click rollback -- so you don't have to script it yourself.

Deploy to Hetzner with DotDeployer · Getting started