Project 3: Zero-Downtime Deploy & Rollback

Build a deploy tool with atomic release switching, health-gated cutover, and a rollback that works when everything else has failed — the script that earns production access.

advanced 55 min lesson hands-on task included

The deploy script is the one everybody writes and nobody reviews, right up until it takes production down at 5pm on a Friday. This one is built around a single idea: the cutover is one atomic operation, and it is reversible.


The Requirements

#RequirementWhy
1Atomic activation — no partial stateUsers must never see a half-deployed tree
2Keep N previous releases on diskRollback must be instant, not a re-download
3Health check gates the cutoverNever route traffic to something unproven
4Automatic rollback on failureThe 3am path must need no thinking
5Pre-flight validation before touching anythingFail before you break, not after
6Deploy lockTwo concurrent deploys is a corrupted release
7Audit trail of who deployed what, whenThe first question of every incident
8--dry-runTrust is built by showing the plan first

Step 1: The Release Layout

Everything depends on this directory structure:

/srv/app/
├── releases/
│   ├── 20260805-141203-a3f9c1/     ← previous
│   ├── 20260805-153044-b7e2d8/     ← previous
│   └── 20260805-161522-c9a4f3/     ← new, fully staged
├── current -> releases/20260805-161522-c9a4f3     ← a SYMLINK
└── shared/
    ├── config/
    ├── uploads/
    └── logs/

Two properties make this work:

Activation is one mv. Replacing a symlink with mv -T is atomic at the kernel level — there is no instant at which current points at nothing. A rm followed by ln -s has a window, however small, where the path does not exist, and that window is where 502s come from.

Rollback is the same operation, backwards. The previous release is still on disk, complete. Rolling back is re-pointing a symlink, which takes milliseconds and cannot fail for lack of network.

Shared state lives outside releases. Uploads, logs and config are symlinked into each release, so they survive both deploy and rollback.


Step 2: Skeleton

#!/usr/bin/env bash
#
# deploy.sh — atomic release deployment with health-gated cutover.
#
set -Eeuo pipefail

readonly SCRIPT_NAME=${0##*/}
readonly VERSION="2.0.0"

readonly APP_ROOT="${APP_ROOT:-/srv/app}"
readonly RELEASES_DIR="${APP_ROOT}/releases"
readonly CURRENT_LINK="${APP_ROOT}/current"
readonly SHARED_DIR="${APP_ROOT}/shared"
readonly KEEP_RELEASES="${KEEP_RELEASES:-5}"
readonly HEALTH_URL="${HEALTH_URL:-http://localhost:8080/health}"
readonly HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-60}"
readonly SERVICE_NAME="${SERVICE_NAME:-app}"
readonly AUDIT_LOG="${APP_ROOT}/deploy-audit.log"

readonly E_USAGE=2 E_PREFLIGHT=3 E_BUILD=4 E_HEALTH=5 E_ROLLBACK=6

DRY_RUN=0
NEW_RELEASE=""
PREVIOUS_RELEASE=""

log()  { printf '%s [%-5s] %s\n' "$(date -Iseconds)" "$1" "${*:2}" >&2; }
info() { log INFO "$@"; }
warn() { log WARN "$@"; }
die()  { log ERROR "${*:1:$#-1}"; exit "${!#}"; }

run() {
    if (( DRY_RUN )); then info "DRY-RUN: $*"; return 0; fi
    "$@"
}

audit() {
    printf '%s\t%s\t%s\t%s\n' \
        "$(date -Iseconds)" "${SUDO_USER:-${USER:-unknown}}" "$1" "${2:-}" >> "$AUDIT_LOG"
}

Step 3: Pre-flight

Everything that can be checked before making a change, is.

preflight() {
    info "running pre-flight checks"

    for cmd in rsync curl systemctl; do
        command -v "$cmd" >/dev/null || die "missing command: ${cmd}" "$E_PREFLIGHT"
    done

    [[ -d $APP_ROOT ]]   || die "APP_ROOT does not exist: ${APP_ROOT}" "$E_PREFLIGHT"
    [[ -w $APP_ROOT ]]   || die "APP_ROOT not writable: ${APP_ROOT}" "$E_PREFLIGHT"
    run mkdir -p "$RELEASES_DIR" "$SHARED_DIR"/{config,uploads,logs}

    # Disk headroom — a deploy that fills the disk breaks the running version too
    local free_mb
    free_mb=$(df -Pm "$APP_ROOT" | awk 'NR==2 {print $4}')
    (( free_mb >= 500 )) || die "only ${free_mb}MB free in ${APP_ROOT}" "$E_PREFLIGHT"

    # The current release must be healthy BEFORE we start
    if [[ -L $CURRENT_LINK ]]; then
        PREVIOUS_RELEASE=$(readlink -f "$CURRENT_LINK")
        info "current release: ${PREVIOUS_RELEASE##*/}"
        if ! health_check 5; then
            warn "the CURRENT release is already unhealthy — deploying anyway"
        fi
    else
        info "no current release; this is the first deploy"
    fi

    info "pre-flight passed"
}

Checking the current release’s health first is what stops the classic confusion where a deploy is blamed for an outage that started twenty minutes earlier.


Step 4: Staging a Release

stage_release() {
    local source=$1
    local stamp revision
    stamp=$(date +%Y%m%d-%H%M%S)
    revision=$(git -C "$source" rev-parse --short HEAD 2>/dev/null || echo "nogit")
    NEW_RELEASE="${RELEASES_DIR}/${stamp}-${revision}"

    info "staging ${source} → ${NEW_RELEASE##*/}"
    run mkdir -p "$NEW_RELEASE"

    run rsync -a --delete \
        --exclude='.git' --exclude='node_modules/.cache' --exclude='*.log' \
        "${source}/" "${NEW_RELEASE}/" \
        || die "rsync failed" "$E_BUILD"

    # Link shared state INTO the release
    local dir
    for dir in config uploads logs; do
        run rm -rf "${NEW_RELEASE:?}/${dir}"
        run ln -sfn "${SHARED_DIR}/${dir}" "${NEW_RELEASE}/${dir}"
    done

    # Anything that must happen before traffic arrives
    if [[ -x "${NEW_RELEASE}/bin/build" ]]; then
        info "running build hook"
        run "${NEW_RELEASE}/bin/build" || die "build hook failed" "$E_BUILD"
    fi

    printf '%s\n' "$revision" > "${NEW_RELEASE}/.revision" 2>/dev/null || true
    info "staged ${NEW_RELEASE##*/}"
}

Note "${NEW_RELEASE:?}/${dir}" in the rm -rf. If NEW_RELEASE were somehow empty, :? aborts rather than letting rm -rf /config run. On a destructive line inside a deploy script, that guard is not paranoia.


Step 5: Health Check

health_check() {
    local timeout="${1:-$HEALTH_TIMEOUT}"
    local deadline=$(( SECONDS + timeout ))
    local attempt=0 code

    info "waiting for health at ${HEALTH_URL} (timeout ${timeout}s)"
    while (( SECONDS < deadline )); do
        (( attempt++ )) || true
        code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 "$HEALTH_URL" 2>/dev/null || echo 000)

        if [[ $code == 200 ]]; then
            info "healthy after ${attempt} attempts"
            return 0
        fi
        sleep 2
    done

    warn "health check FAILED after ${timeout}s (last status ${code})"
    return 1
}

Using $SECONDS for the deadline rather than counting iterations means the timeout is honest even when individual curl calls take their full 5 seconds.


Step 6: Atomic Activation

activate() {
    local target=$1
    info "activating ${target##*/}"

    # mv -T on a symlink is ATOMIC — no window where `current` is missing
    run ln -sfn "$target" "${CURRENT_LINK}.tmp"
    run mv -Tf "${CURRENT_LINK}.tmp" "$CURRENT_LINK"

    if systemctl list-units --type=service --all 2>/dev/null | grep -q "${SERVICE_NAME}.service"; then
        info "reloading ${SERVICE_NAME}"
        run systemctl reload-or-restart "$SERVICE_NAME" || die "service reload failed" "$E_HEALTH"
    fi
}

rollback_to() {
    local target=$1
    warn "ROLLING BACK to ${target##*/}"
    activate "$target"
    if health_check 30; then
        warn "rollback complete and healthy"
        audit "rollback-success" "${target##*/}"
        return 0
    fi
    log ERROR "ROLLBACK FAILED — the previous release is also unhealthy. Manual intervention required."
    audit "rollback-failed" "${target##*/}"
    return 1
}

ln -sfn to a temporary name followed by mv -Tf is the atomic-symlink-swap idiom. -T treats the destination as a file rather than descending into it — without it, mv would move the link inside the existing current directory.


Step 7: The Deploy Flow

cmd_deploy() {
    local source="."
    local OPTIND=1
    while getopts ":s:h" opt; do
        case $opt in
            s) source=$OPTARG ;;
            h) echo "usage: ${SCRIPT_NAME} deploy [-s SOURCE]"; return 0 ;;
            \?) die "deploy: unknown option -${OPTARG}" "$E_USAGE" ;;
        esac
    done

    [[ -d $source ]] || die "source not found: ${source}" "$E_USAGE"

    exec 200>"${APP_ROOT}/.deploy.lock"
    flock -n 200 || die "another deploy is in progress" "$E_USAGE"

    audit "deploy-start" "$source"
    preflight
    stage_release "$source"
    activate "$NEW_RELEASE"

    if health_check; then
        info "DEPLOY SUCCESSFUL: ${NEW_RELEASE##*/}"
        audit "deploy-success" "${NEW_RELEASE##*/}"
        prune_releases
        return 0
    fi

    # The whole point of the project
    warn "new release is unhealthy — initiating automatic rollback"
    audit "deploy-failed" "${NEW_RELEASE##*/}"

    if [[ -z $PREVIOUS_RELEASE ]]; then
        die "no previous release to roll back to" "$E_HEALTH"
    fi

    rollback_to "$PREVIOUS_RELEASE" || exit "$E_ROLLBACK"
    exit "$E_HEALTH"
}

cmd_rollback() {
    [[ -L $CURRENT_LINK ]] || die "nothing is currently deployed" "$E_USAGE"
    local current; current=$(readlink -f "$CURRENT_LINK")

    # The newest release that is NOT the current one
    local -a releases
    mapfile -t releases < <(find "$RELEASES_DIR" -maxdepth 1 -mindepth 1 -type d | sort -r)

    local target=""
    local r
    for r in "${releases[@]}"; do
        [[ $r == "$current" ]] && continue
        target=$r
        break
    done

    [[ -n $target ]] || die "no previous release available" "$E_USAGE"
    audit "manual-rollback" "${target##*/}"
    rollback_to "$target" || exit "$E_ROLLBACK"
}

prune_releases() {
    local -a releases
    mapfile -t releases < <(find "$RELEASES_DIR" -maxdepth 1 -mindepth 1 -type d | sort -r)
    (( ${#releases[@]} <= KEEP_RELEASES )) && return 0

    local current=""
    [[ -L $CURRENT_LINK ]] && current=$(readlink -f "$CURRENT_LINK")

    local i
    for (( i = KEEP_RELEASES; i < ${#releases[@]}; i++ )); do
        [[ ${releases[i]} == "$current" ]] && continue      # never delete what is live
        info "pruning ${releases[i]##*/}"
        run rm -rf "${releases[i]:?}"
    done
}

cmd_status() {
    if [[ -L $CURRENT_LINK ]]; then
        local c; c=$(readlink -f "$CURRENT_LINK")
        printf 'current:  %s\n' "${c##*/}"
        printf 'health:   %s\n' "$(health_check 5 >/dev/null 2>&1 && echo healthy || echo UNHEALTHY)"
    else
        echo "current:  (nothing deployed)"
    fi
    echo
    echo "releases on disk:"
    find "$RELEASES_DIR" -maxdepth 1 -mindepth 1 -type d -printf '  %f\n' 2>/dev/null | sort -r
    echo
    echo "recent deploys:"
    tail -5 "$AUDIT_LOG" 2>/dev/null || echo "  (no audit log)"
}

Step 8: Dispatch and CI

main() {
    while getopts ":nh" opt; do
        case $opt in
            n) DRY_RUN=1 ;;
            h) usage; exit 0 ;;
            \?) die "unknown option -${OPTARG}" "$E_USAGE" ;;
        esac
    done
    shift $(( OPTIND - 1 ))

    (( $# > 0 )) || { usage >&2; exit "$E_USAGE"; }
    local sub=$1; shift
    case $sub in
        deploy|rollback|status) "cmd_${sub}" "$@" ;;
        help) usage; exit 0 ;;
        *) die "unknown command: ${sub}" "$E_USAGE" ;;
    esac
}

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    main "$@"
fi
# .github/workflows/deploy.yml
- name: Deploy
  run: |
    rsync -az --delete ./dist/ deploy@prod:/tmp/incoming/
    ssh -n deploy@prod '/usr/local/bin/deploy.sh deploy -s /tmp/incoming'

Exit code 5 (health failure, rolled back) is distinct from 6 (rollback itself failed) — so CI can mark the first as a failed deploy and the second as a page someone immediately.


Verifying Your Work

export APP_ROOT=/tmp/deploytest HEALTH_URL=http://localhost:8080/health

# 1. First deploy
mkdir -p /tmp/src && echo v1 > /tmp/src/version.txt
./deploy.sh deploy -s /tmp/src
cat /tmp/deploytest/current/version.txt        # v1

# 2. Second deploy — current must switch atomically
echo v2 > /tmp/src/version.txt
./deploy.sh deploy -s /tmp/src
cat /tmp/deploytest/current/version.txt        # v2

# 3. THE test: deploy something that fails its health check
#    (point HEALTH_URL at a dead port)
HEALTH_URL=http://localhost:9999/health HEALTH_TIMEOUT=10 ./deploy.sh deploy -s /tmp/src
cat /tmp/deploytest/current/version.txt        # still the last GOOD release

# 4. Manual rollback
./deploy.sh rollback
./deploy.sh status

# 5. Concurrency
./deploy.sh deploy -s /tmp/src & ./deploy.sh deploy -s /tmp/src; wait

# 6. Retention
for i in {1..8}; do echo "v$i" > /tmp/src/version.txt; ./deploy.sh deploy -s /tmp/src; done
ls /tmp/deploytest/releases | wc -l            # KEEP_RELEASES

Step 3 is the project. Everything else is scaffolding around the requirement that a bad release cannot stay live.

Extensions worth building: blue/green with two symlinks and a load-balancer flip; database migrations with a paired down step run on rollback; canary — activate for 10% of traffic and compare error rates before full cutover; a Slack notification on deploy start, success and rollback using the alerting project’s notify function.

The lesson to take away: rollback is not an error path bolted on at the end — it is the primary feature. Design the layout so that reverting is the cheapest operation in the system, and a bad deploy stops being an incident and becomes a non-event.