learning-website-framework1-8 Exercise 2: Atomic Deploys with Rollback ==================================================================== Today the content is synced to the server and into /var/www/html. A sync that overwrites live files shows visitors half-copied pages and cannot be undone. Write a deploy script that copies each build into a new timestamped release folder and then switches a 'current' link to it in one atomic step, with a rollback script, tested for real. Save as deploy.sh: #!/usr/bin/env bash # deploy.sh # Copies a finished build into a new timestamped release and switches the # "current" link to it in one atomic step. Keeps the last $KEEP releases. set -euo pipefail SITE="${1:?usage: deploy.sh }" BUILD="${2:?usage: deploy.sh }" ROOT="${WEB_ROOT:-/var/www}" KEEP="${KEEP:-5}" BASE="$ROOT/$SITE" REL="$BASE/releases/$(date +%Y%m%d-%H%M%S-%N)" [ -d "$BUILD" ] || { echo "build folder not found: $BUILD" >&2; exit 1; } mkdir -p "$BASE/releases" mkdir "$REL" # no -p: fail if this release name already exists rsync -a --delete "$BUILD"/ "$REL"/ # refuse to publish an empty or broken build if [ ! -f "$REL/index.html" ]; then echo "no index.html in the build; not switching" >&2 rm -rf "$REL" exit 1 fi # atomic switch: make the new link beside the old one, then rename over it ln -sfn "$REL" "$BASE/current.new" mv -Tf "$BASE/current.new" "$BASE/current" echo "deployed $SITE -> $(basename "$REL")" # prune old releases: names are timestamps, so sort by NAME (not by modified # time, which rsync -a copies from the build folder). Never delete the live one. LIVE="$(readlink "$BASE/current")" ls -1d "$BASE"/releases/* | sort -r | tail -n +"$((KEEP + 1))" | { grep -v -x -F "$LIVE" || true; } | xargs -r rm -rf Save as rollback.sh: #!/usr/bin/env bash # rollback.sh - point "current" at the release before the live one. set -euo pipefail SITE="${1:?usage: rollback.sh }" ROOT="${WEB_ROOT:-/var/www}" BASE="$ROOT/$SITE" LIVE="$(basename "$(readlink "$BASE/current")")" # releases sorted oldest to newest; take the one just before the live one PREV="$(ls -1 "$BASE/releases" | sort | grep -B1 -x "$LIVE" | head -n1)" if [ -z "$PREV" ] || [ "$PREV" = "$LIVE" ]; then echo "no earlier release to roll back to" >&2 exit 1 fi ln -sfn "$BASE/releases/$PREV" "$BASE/current.new" mv -Tf "$BASE/current.new" "$BASE/current" echo "rolled back $SITE: $LIVE -> $PREV" Save as test_deploy.sh (runs everything in a temporary folder): #!/usr/bin/env bash set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" T="$(mktemp -d)" export WEB_ROOT="$T/www" KEEP=2 mkdir -p "$T/build1" "$T/build2" "$T/build3" "$T/empty" echo "version one" > "$T/build1/index.html" echo "version two" > "$T/build2/index.html" echo "version three" > "$T/build3/index.html" for b in build1 build2 build3; do bash "$HERE/deploy.sh" languages "$T/$b" echo " live page says: $(cat "$WEB_ROOT/languages/current/index.html")" sleep 1 done echo "releases kept (KEEP=2): $(ls "$WEB_ROOT/languages/releases" | wc -l)" bash "$HERE/rollback.sh" languages echo " live page says: $(cat "$WEB_ROOT/languages/current/index.html")" echo "--- deploy again after a rollback: the newest release is kept, the live one is never pruned" bash "$HERE/deploy.sh" languages "$T/build2" echo " live page says: $(cat "$WEB_ROOT/languages/current/index.html")" echo " releases now: $(ls "$WEB_ROOT/languages/releases" | wc -l)" echo "--- an empty build must not go live" bash "$HERE/deploy.sh" languages "$T/empty" || echo " refused (exit $?)" echo " live page still says: $(cat "$WEB_ROOT/languages/current/index.html")" rm -rf "$T" Run it (this was run on Linux under WSL): bash test_deploy.sh Output (checked by running it): deployed languages -> 20261006-141535-441609694 live page says: version one deployed languages -> 20261006-141536-543301984 live page says: version two deployed languages -> 20261006-141537-635563722 live page says: version three releases kept (KEEP=2): 2 rolled back languages: 20261006-141537-635563722 -> 20261006-141536-543301984 live page says: version two --- deploy again after a rollback: the newest release is kept, the live one is never pruned deployed languages -> 20261006-141538-769040231 live page says: version two releases now: 2 --- an empty build must not go live refused (exit 1) live page still says: version two no index.html in the build; not switching Two bugs the test caught in my first versions --------------------------------------------- 1. Pruning by modified time deleted the wrong release, because rsync -a copies each folder's modified time from the build folder. Release names are timestamps, so prune by NAME, and never delete the live release. 2. Two deploys in the same second shared one release name, so a refused (empty) deploy emptied and then deleted the LIVE release. Fix: add nanoseconds to the name and create it with mkdir without -p, so an existing name fails instead of being reused. Takeaway: scripts that delete things need a test that tries to hurt them. Design points ------------- - ln -sfn then mv -Tf swaps the link atomically: a request sees either the old release or the new one, never a mixture. - Refusing a build with no index.html stops an empty build going live. - The old release stays on disk, so rollback is instant and needs no rebuild. - Releases and a link also give you a place to put per-release notes (build date, source commit). WHY THIS WORKS AS AN ANSWER --------------------------- It replaces "copy over the live files and hope" with a switch that either happens completely or not at all, and a way back that takes a second. The real value is in the test: it deploys, rolls back, deploys again after a rollback and attempts a bad deploy, which is where the bugs were.