September 12, 2026
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.
2. Repoint the symlink, then restart
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.
5. Rollback is the same symlink swap, backwards
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.