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://.
$curl -s -o /dev/null -w '%{http_code}' https:///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):
$brew install rsyncThen 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:
$rsync --version | head -1$rsync --version | head -1 | grep -o 'version 3'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:
$[ -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.pubThen log in to the server as usual:
$ssh vpsOn 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:
$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$exitAdd the laptop alias
Add a second host to ~/.ssh/config on your laptop, next to vps:
$cat >> ~/.ssh/config <<'EOF'$$Host vps-deploy$ HostName $ Port $ User $ IdentityFile ~/.ssh/id_ed25519_deploy$ IdentitiesOnly yes$EOF$ssh vps-deploy 'ls '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:
$cd && mkdir -p scriptsThen 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.
#!/usr/bin/env bash# Build the site and publish it as a new release. Usage: ./scripts/deploy.shset -euo pipefailcd "$(dirname "$0")/.."HOST=vps-deployROOT=KEEP=[ "$KEEP" -ge 1 ] || { echo "KEEP_RELEASES must be at least 1" >&2; exit 1; }npm cinpm run buildtest -f out/index.htmlREL=$(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:
$chmod +x scripts/deploy.shDeploy 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.
$cd $./scripts/deploy.sh$ssh vps-deploy 'readlink /current | cut -c1-11'releases/20
$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:
#!/usr/bin/env bash# Usage: ./scripts/rollback.sh [release-name]set -euo pipefailHOST=vps-deployROOT=CUR=$(ssh "$HOST" "readlink $ROOT/current")CUR=$(basename "$CUR")LIST=$(ssh "$HOST" "cd $ROOT/releases && ls -1d 20* | sort")if [ $# -gt 0 ]; then TARGET=$1else 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:
$chmod +x scripts/rollback.sh$./scripts/deploy.sh$ssh vps-deploy 'ls -1 /releases'$./scripts/rollback.shDone
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.