Articles › Servers and DevOps

Deploy in one swap: no half-uploaded site, and rollback in one command

Copying over a live web folder serves half-uploaded pages. The folder swap this site uses, its small gap, and the symlink way that swaps instantly, with rollback.

Intermediate 5 min read October 16, 2026

What you will learn
  • Never upload over what is being served. Prepare the new version completely elsewhere, then swap in one move
  • A rename on the same disk is instant, with no in-between state, but a two-step swap still leaves a short gap
  • Always keep the previous version; rollback should be one command you do not have to think about

The simplest deploy copies new files over the web folder. It mostly works, but during the few seconds files are arriving, a visitor may get the new HTML pointing at a CSS file that has not landed yet, so the page shows with no styling, or old HTML mixed with new JavaScript that fails in ways nobody can explain. This article shows how this site deploys every time, the small weakness that remains, and a method without it.

Tested on Ubuntu 24.04 with nginx. This site is a static Astro site of about 1.3MB.

The problem with copying over

Modern build tools name CSS and JavaScript files after their content, such as index.xB_X6hmL.css; change the content and the name changes. That lets browsers cache these files for a year, but it also means new HTML only works with the new files.

When copying over, files arrive in no particular order:

MomentIn the web folderA visitor right now
second 0all old filesfine
second 1new index.html, new CSS not here yetunstyled page
second 2completefine

Principle: never modify what is being served. Prepare the new version completely elsewhere, then swap in one move.

What this site does

The whole deploy is one script, run with npm run deploy:

npm run build
tar -C dist -czf /tmp/sixam-dist.tgz .
scp -q /tmp/sixam-dist.tgz prod:/tmp/sixam-dist.tgz
ssh prod 'set -e
  rm -rf /tmp/sixam-new && mkdir -p /tmp/sixam-new
  tar -C /tmp/sixam-new -xzf /tmp/sixam-dist.tgz
  sudo rm -rf /var/www/6amdev.old
  if [ -d /var/www/6amdev ]; then sudo mv /var/www/6amdev /var/www/6amdev.old; fi
  sudo mv /tmp/sixam-new /var/www/6amdev
  sudo chown -R www-data:www-data /var/www/6amdev
  curl -s -o /dev/null -w "6amdev.com %{http_code}\n" https://6amdev.com/'

Step by step:

  1. Build locally and pack it into one file. One file transfers faster than hundreds of small ones, and cannot be half-sent and served
  2. Unpack into a new folder, /tmp/sixam-new, which nobody sees yet. If this fails, set -e stops the script and the live site is never touched
  3. Move the old version to .old, kept for rollback
  4. Move the new version into place
  5. Check the site answers 200

Why mv is instant

mv within the same disk copies nothing; it only renames an entry in the filesystem’s directory. It happens at once however large the folder, with no in-between state: it has either been renamed or not.

But it must really be the same disk. If /tmp is another partition or lives in memory (tmpfs), mv becomes copy-then-delete, and the half-finished state is back. Check with:

df -h /tmp /var/www

On our machine both are on the same /dev/vda1.

The gap that remains

This is far better than copying over, but not perfect. Between step 3 and step 4 there is a fraction of a second when /var/www/6amdev does not exist at all, and a request landing exactly then gets a 404. The gap is tiny and acceptable for a knowledge site, but a system in constant use should close it completely.

One smaller point: chown runs after the swap. It belongs before it, so the new version is complete from the very first request.

1unpack into /tmp/sixam-newnobody sees it yet2mv 6amdev → 6amdev.oldkept for rollback!a split second with no folderrequests here get 4043mv sixam-new → 6amdeva rename, not a copy4curl → 200check the site answerssymlink: mv -T current.tmp currentone rename, no gap
This site's folder swap leaves a split-second gap between the two mv commands; the symlink way closes it

Instead of nginx pointing at a real folder, point it at a symlink called current, with each release in its own folder:

/var/www/6amdev/
├── releases/
│   ├── 20261015-1731/
│   └── 20261016-0930/
└── current -> releases/20261016-0930

nginx uses root /var/www/6amdev/current;. To deploy:

REL=/var/www/6amdev/releases/$(date +%Y%m%d-%H%M)
mkdir -p "$REL" && tar -C "$REL" -xzf /tmp/site.tgz
chown -R www-data:www-data "$REL"
ln -s "$REL" /var/www/6amdev/current.tmp
mv -T /var/www/6amdev/current.tmp /var/www/6amdev/current

The last line is the heart of it. mv -T renames the new symlink over the old one in a single rename, so every request sees either the old release or the new one, never nothing. Creating the link under a temporary name and then mv -T makes the swap a single rename for certain, without depending on how a particular tool version works inside.

Rolling back

Every method must roll back in one command, because when you need it you are in the middle of a problem and should not have to think.

With this site’s folder swap:

cd /var/www && sudo mv 6amdev 6amdev.bad && sudo mv 6amdev.old 6amdev

With symlinks, point current back at the previous release:

ln -s /var/www/6amdev/releases/20261015-1731 /var/www/6amdev/current.tmp
mv -T /var/www/6amdev/current.tmp /var/www/6amdev/current

The symlink layout keeps several releases; prune anything older than five now and then. The folder swap keeps just one, which is enough for a small site.

Check more than the home page afterwards

The script only checks that the home page returns 200, which is the bare minimum. Whenever pages are added we also check:

  • The new pages, in both languages
  • One CSS file, for its full set of headers (see the article on security headers)
  • The other sites on the same machine still open, because several sites share this nginx

Common mistakes

  • Copying or rsyncing over the live folder
  • Unpacking into a tmpfs /tmp and assuming mv is an instant rename
  • Keeping no previous version, so recovery means rebuilding while the site is down
  • A rollback that takes several steps and some thinking
  • Swapping a symlink by deleting the old one and then creating the new one, which leaves a moment with no symlink
  • Checking only the home page after a deploy

FAQ

Does this work for sites with a backend?

The same principle applies, but the database needs thought too. If a deploy changes table structure, rolling back the code may not be enough. Design schema changes so old and new code both work during the transition.

Why not just use rsync?

rsync is great for transferring files, but rsyncing straight into the live folder still serves a half-finished state while files arrive. rsync into a new folder, then swap, and you get the best of both.

Commands and settings were tested on sample systems. Try them on a test machine before production.