Deploy scripts are where shell habits compound: they run rarely, they touch production, and they fail in front of people. Taking one real script apart line by line shows every habit in this series paying rent at once.
The whole script #
#!/usr/bin/env bash
set -euo pipefail
usage() { echo "usage: $0 <environment>" >&2; exit 2; }
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2; }
[[ $# -eq 1 ]] || usage
env="$1"
case "$env" in
prod) host="aospix.com" ;;
dev) host="dev.aospix.com" ;;
*) usage ;;
esac
workdir=$(mktemp -d)
trap 'rm -rf "$workdir"' EXIT
log "building for $env"
npm run build -- --env "$env"
log "running smoke tests"
test -f dist/index.html || { log "build output missing"; exit 1; }
log "uploading to $host"
rsync -av --delete dist/ "deploy@$host:/srv/app/releases/staging/"
log "switching release"
ssh "deploy@$host" 'ln -sfn /srv/app/releases/staging /srv/app/current'
log "verifying"
curl -sf "https://$host/health" || { log "health check failed"; exit 1; }
log "done"
What each block is doing #
| Block | Habit from this series | ||
|---|---|---|---|
set -euo pipefail | Strict mode: any failure stops the deploy | ||
usage / log | Functions: errors to stderr, logs timestamped | ||
| `[[ $# -eq 1 ]] \ | \ | usage` | Argument guards with a non-zero exit code |
case "$env" | Dispatch on values, unknown ones rejected | ||
mktemp + trap EXIT | Cleanup on success, error, or Ctrl+C | ||
test -f before shipping | Verify the artifact exists before pointing traffic at it | ||
rsync --delete into releases/, then a symlink flip | The deploy and the cutover are separate, reversible steps | ||
| `curl -sf ... \ | \ | exit 1` | Verify after switching — and pipefail keeps curl honest in the pipe |
Why this shape survives contact with reality #
Three properties are deliberate. Idempotent: run it twice, it recovers (rsync and the symlink flip are both). Two-phase: artifacts upload to a staging path, and only a symlink flip makes them live — rollback is flipping the link back, no rebuild. Observable: every step logs to stderr with a timestamp, so the transcript answers "what did it do" after the fact.
What it deliberately does not do: interactive prompts (unattended hostile), parallel steps (failure attribution becomes mush), or in-place edits on the server (no rollback).
The upgrade paths #
Growth in order of pain: a --dry-run flag that adds -n to rsync and skips the flip; getopts once flags multiply; a lock via mkdir to stop overlapping runs; and eventually a real CI job — GitHub Actions — calling this same script, because a script that is honest about its steps is exactly what CI wants to run.
Every ingredient here has its own note in the scripting series; the terminal notes index ties the whole toolkit together.
