Deploy atomic releases with rsync

One command on your laptop builds the site, uploads only what changed into a new release directory, switches the live site to it in a single atomic step, keeps the last few releases and checks the result. Rollback is one command too.

intermediate~30 min hands-on
#deploy#rsync#ssh#symlink#rollback

Not validated end to end yet — be the first.Report a problem

Draft — not yet run end to end. This page was written but its author has not yet run it on a real machine. Commands may be wrong: read before you run, and tell us what breaks.

The gistBuild, upload, swap one link
Your VPS
npm run buildrsync over SSHatomic swapservesHTTPS
Your laptop./scripts/deploy.sh
out/npm run build
New releasereleases/<UTC timestamp>
currentsymlink
Caddy/current
Visitorshttps://

Each deploy lands in its own directory under releases/. The live site is whatever current points to, and moving that link is a single atomic rename, so visitors never see a half-uploaded site.

The site is served, but only a placeholder. This page gives you one command, ./scripts/deploy.sh, that builds the site on your laptop, uploads only what changed into a new release directory on the server, switches the live site to it in one atomic step, deletes old releases beyond the last , and checks that https:// answers. A second command rolls back. The uploads go through a dedicated account, , with its own key, no sudo, and the web root as the only thing it owns.

Before you start

You need pages 1 to 5 done: the project folder on your laptop (page 1), ssh vps logging you in as , and Caddy serving the placeholder at https://.

Check
$curl -s -o /dev/null -w '%{http_code}' https:///
Expected output
200

The Mac's own rsync is not the one you want. Install rsync 3 from Homebrew (install Homebrew from brew.sh first if brew is missing):

Mac
$brew install rsync

Then close this terminal and open a new one (Cmd+N in Terminal). The shell that is already open may keep running /usr/bin/rsync: it remembers where it found a command, and if Homebrew was installed in it, its PATH does not include /opt/homebrew/bin yet. A new shell reads your PATH afresh and finds Homebrew's rsync first. In the new terminal:

Mac
$rsync --version | head -1
Check
$rsync --version | head -1 | grep -o 'version 3'
Expected output
version 3

Create the deploy user

Your admin account has sudo; the account that uploads the site must not, and it gets a key of its own. On the laptop, create the deploy key if it does not exist yet and copy its public half to the server:

Mac
$[ -f ~/.ssh/id_ed25519_deploy ] || ssh-keygen -t ed25519 -N "" -C "deploy@laptop" -f ~/.ssh/id_ed25519_deploy
$scp ~/.ssh/id_ed25519_deploy.pub vps:deploy.pub

Then log in to the server as usual:

Mac
$ssh vps

On the server, install rsync (the receiving end), create the user, let it through SSH, give it the web root, and install the deploy key for it, prefixed with restrict. The last line, exit, brings you back to the laptop:

Server·
$sudo apt install -y rsync
$sudo adduser --disabled-password --gecos ""
$sudo usermod -aG sshusers
$sudo chown -R :
$sudo install -d -m 700 -o -g /home//.ssh
$sed 's/^/restrict /' deploy.pub | sudo tee /home//.ssh/authorized_keys
$sudo chown : /home//.ssh/authorized_keys
$sudo chmod 600 /home//.ssh/authorized_keys
$rm deploy.pub
$exit

Add the laptop alias

Add a second host to ~/.ssh/config on your laptop, next to vps:

Mac
$cat >> ~/.ssh/config <<'EOF'
$
$Host vps-deploy
$ HostName
$ Port
$ User
$ IdentityFile ~/.ssh/id_ed25519_deploy
$ IdentitiesOnly yes
$EOF
Check
$ssh vps-deploy 'ls '
Expected output
current
releases
If it says Permission denied (publickey)

In order of likelihood: the user is not in the sshusers group, the permissions on /home//.ssh are too open, the key line lost its ssh-ed25519 prefix, or the IdentityFile path does not match the key you created. On the server, sudo journalctl -u ssh -n 20 names the reason.

Write the deploy script

Create the scripts folder in the project:

Mac
$cd && mkdir -p scripts

Then write scripts/deploy.sh: copy the command and paste it in the same terminal. The path is relative, so it lands in the project folder you just moved into.

Macwrites a filescripts/deploy.sh
Sensitive command — recursive or forced deletion (rm). Review before running.
#!/usr/bin/env bash
# Build the site and publish it as a new release. Usage: ./scripts/deploy.sh
set -euo pipefail
cd "$(dirname "$0")/.."
HOST=vps-deploy
ROOT=
KEEP=
[ "$KEEP" -ge 1 ] || { echo "KEEP_RELEASES must be at least 1" >&2; exit 1; }
npm ci
npm run build
test -f out/index.html
REL=$(date -u +%Y%m%dT%H%M%SZ)
echo "Release $REL"
rsync -rlpz --checksum --delete --chmod=D755,F644 \
--link-dest="$ROOT/current/" \
out/ "$HOST:$ROOT/releases/$REL/"
ssh "$HOST" "cd $ROOT && ln -sfn releases/$REL current.tmp && mv -T current.tmp current"
ssh "$HOST" "cd $ROOT/releases && ls -1d 20* | sort | head -n -$KEEP | xargs -r rm -rf --"
curl -fsS -o /dev/null -w '%{http_code}\n' https:///

Make it executable, so it runs as ./scripts/deploy.sh:

Mac
$chmod +x scripts/deploy.sh

Deploy for the first time

Run the script from the project folder. The last line of the output should be 200; open https:// and your site replaces the placeholder. The first upload sends the whole site (about 220 MB with the media in public/); the next ones send only what changed.

Mac
$cd
$./scripts/deploy.sh
Check
$ssh vps-deploy 'readlink /current | cut -c1-11'
Expected output
releases/20
Mac
Sensitive command — recursive or forced deletion (rm). Review before running.
$ssh vps-deploy 'rm -rf /releases/placeholder'

Roll back

Rolling back is the swap again, pointed at an older release. This script points current at the release immediately before the live one, or at the one you name. Copy the command and paste it in the terminal, in the project folder:

Macwrites a filescripts/rollback.sh
#!/usr/bin/env bash
# Usage: ./scripts/rollback.sh [release-name]
set -euo pipefail
HOST=vps-deploy
ROOT=
CUR=$(ssh "$HOST" "readlink $ROOT/current")
CUR=$(basename "$CUR")
LIST=$(ssh "$HOST" "cd $ROOT/releases && ls -1d 20* | sort")
if [ $# -gt 0 ]; then
TARGET=$1
else
TARGET=$(printf '%s\n' "$LIST" | awk -v c="$CUR" '$0 == c { print p; exit } { p = $0 }')
fi
[ -n "$TARGET" ] || { echo "No release older than $CUR." >&2; exit 1; }
printf '%s\n' "$LIST" | grep -qx "$TARGET" || { echo "Unknown release: $TARGET" >&2; exit 1; }
ssh "$HOST" "cd $ROOT && ln -sfn releases/$TARGET current.tmp && mv -T current.tmp current"
echo "current: $CUR -> $TARGET"
curl -fsS -o /dev/null -w '%{http_code}\n' https:///

Make it executable, deploy once more so the server holds two releases, list them, and roll back to the first:

Mac
$chmod +x scripts/rollback.sh
$./scripts/deploy.sh
$ssh vps-deploy 'ls -1 /releases'
$./scripts/rollback.sh

Done

A deploy is now one command, ./scripts/deploy.sh, and a rollback another, ./scripts/rollback.sh. Each release is a directory named by its UTC deploy time, the live one is whatever current points to, and the switch between them is a single rename. The server keeps the last .

The next page, Backups, monitoring and the monthly routine, makes sure you hear about it when the site goes down, and that the server can be rebuilt if the VPS is lost.

Did everything work?

If you followed this page to the end on a real machine, say so. Your validation is dated and records your stack, so the next reader on the same path knows it still works.

This copy is read-only. To report that it works, or that it does not, open an issue

Only your stack choices are recorded, never your values. The pseudonym stays on this browser.