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 #

deploy.sh — a complete small deploy
#!/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 #

BlockHabit from this series
set -euo pipefailStrict mode: any failure stops the deploy
usage / logFunctions: 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 EXITCleanup on success, error, or Ctrl+C
test -f before shippingVerify the artifact exists before pointing traffic at it
rsync --delete into releases/, then a symlink flipThe 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.