← Automatización con Scripts en Bash Bloque 9 · Automatización en la práctica · Tema 39 de 42

Tema 39

Despliegues por SSH

El tema 36 terminaba con un paquete listo para entregar. Entregarlo, es decir, ponerlo a funcionar en el servidor donde lo usan las personas, es un despliegue, y es el momento más delicado de la vida de un programa: un error ahí no lo sufre quien lo programó, sino quienes lo usan. Este tema construye un despliegue por SSH que no deja nunca el servidor a medio actualizar, que comprueba que la versión nueva funciona antes de darla por buena, y que vuelve sola a la anterior si algo sale mal.

1. Qué es desplegar

Desplegar es instalar una versión nueva de un programa en el lugar donde se ejecuta, típicamente un servidor, y ponerla en funcionamiento en reemplazo de la anterior. Un buen despliegue es repetible, rápido, deja el sistema siempre en un estado coherente, y permite volver a la versión anterior si la nueva falla.

La forma ingenua es copiar los archivos nuevos encima de los viejos y reiniciar. Funciona casi siempre, y por eso es tan común, pero tiene tres problemas serios:

La técnica de este tema, que usan desde hace años herramientas como Capistrano o Deployer, resuelve los tres con una idea simple: cada versión va a su propio directorio, y un enlace simbólico decide cuál es la activa.

2. Una versión por directorio

La estructura en el servidor, dentro de /srv/tienda: el directorio releases contiene una carpeta por versión, 20261013-091500, 20261014-163210 y 20261015-103012; el enlace simbólico current apunta a la más reciente, que es la activa; el directorio shared contiene config.env y subidas, y cada versión tiene enlaces a ellos. El servidor web y el servicio usan siempre /srv/tienda/current /srv/tienda/ releases/ 20261013-091500/ 20261014-163210/ 20261015-103012/ current usan siempre esta ruta shared/ config.env subidas/ enlace Cada versión, completa e intacta. Cambiar de versión es cambiar a dónde apunta current. Las líneas punteadas son los enlaces de cada versión a lo compartido.
La estructura en el servidor. El servicio y el servidor web solo conocen /srv/tienda/current.

Con esta estructura, desplegar es copiar la versión nueva a su directorio, sin tocar la que está funcionando, y al final cambiar el enlace. Volver atrás es cambiar el enlace otra vez.

3. SSH en un script

Un script de despliegue no puede detenerse a pedir una contraseña. La conexión se hace con una clave, idealmente una dedicada a los despliegues, y con un usuario del servidor que tenga solo los permisos necesarios:

terminal · una clave para desplegar
$ ssh-keygen -t ed25519 -f ~/.ssh/despliegue_ed25519 -C "despliegues de ana"
$ ssh-copy-id -i ~/.ssh/despliegue_ed25519.pub deploy@web1.example.com

Los detalles de la conexión conviene escribirlos una vez en ~/.ssh/config, con un nombre corto, en lugar de repetirlos en cada comando:

~/.ssh/config
Host web1
    HostName web1.example.com
    User deploy
    IdentityFile ~/.ssh/despliegue_ed25519
    ControlMaster auto
    ControlPath ~/.ssh/control-%r@%h:%p
    ControlPersist 60

Las tres últimas líneas activan la multiplexación: la primera conexión queda abierta un minuto, y las siguientes viajan por ella sin negociar de nuevo. Un despliegue hace varias llamadas a ssh y a rsync, y la diferencia se nota. En el script, además, cada llamada lleva dos opciones:

Y un recordatorio del tema 16: ssh lee la entrada estándar, de modo que dentro de un bucle while read se come las líneas siguientes. Ahí se usa ssh -n.

4. Los comandos remotos y las comillas

ssh servidor comando ejecuta el comando en el servidor, y es el detalle que más errores causa en los scripts de despliegue. ssh no envía los argumentos por separado: los une con espacios en una sola línea, y la shell del servidor la vuelve a dividir. Las comillas locales protegen los argumentos de la shell local, pero no de la remota:

terminal · dos shells, dos lecturas
$ archivo="informe final.txt"
$ ssh web1 ls -l "$archivo"
ls: cannot access 'informe': No such file or directory
ls: cannot access 'final.txt': No such file or directory
$ ssh web1 ls -l "$(printf '%q' "$archivo")"
-rw-r--r-- 1 deploy deploy 812 oct 15 10:02 informe final.txt

printf '%q' escribe un valor con las comillas y barras necesarias para que una shell lo lea como un solo argumento, exactamente como era. Es la forma correcta de pasar datos a la shell remota. Para algo más largo que un comando, la técnica más limpia es no armar una línea en absoluto, sino enviar un script por la entrada estándar a un bash remoto, y los datos como argumentos:

un script remoto, con argumentos
remoto() {                             # remoto SCRIPT ARG...: ejecuta SCRIPT con bash en el servidor
  local script="$1"
  shift
  ssh "${ssh_opciones[@]}" "$servidor" "bash -s -- $(printf '%q ' "$@")" <<< "set -euo pipefail
shopt -s nullglob dotglob
$script"
}

remoto 'cd "$1" && ls -1 releases' /srv/tienda

bash -s lee el programa de la entrada estándar, y lo que sigue a -- son sus argumentos $1, $2…, protegidos con printf '%q'. El script se escribe entre comillas simples, de modo que $1 no lo expande la shell local sino la remota, y se le antepone el modo estricto, que así rige también en el servidor. Es la forma que usa el script de este tema para todo lo que hace allí. Una precaución: printf '%q' produce comillas de Bash, de modo que la shell del usuario remoto tiene que ser Bash, o una compatible.

5. Copiar la versión con rsync

La versión nueva se copia con rsync a su propio directorio. Como la mayoría de los archivos no cambian de una versión a otra, conviene la técnica de instantáneas del tema 37: --link-dest apuntando a la versión activa hace que lo que no cambió se enlace en lugar de transferirse, y la copia lleva segundos aunque el programa sea grande.

copiar la versión nueva
rsync -az --delete -e "ssh -o BatchMode=yes" \
      --link-dest=/srv/tienda/releases/20261014-163210/ \
      -- build/paquete/tienda/ web1:/srv/tienda/releases/20261015-103012/

-z comprime durante la transferencia, útil por la red; -e indica el comando ssh con sus opciones. La barra final del origen copia su contenido, como vimos en el tema 37. Y --link-dest lleva una ruta absoluta en el servidor. Lo que se copia debería ser el resultado de la compilación, el directorio que preparó build.sh, y no el directorio de trabajo con sus archivos temporales; si hay que copiar un directorio con cosas que no van, se excluyen con --exclude, como .git/ o node_modules/.

6. Lo compartido y la preparación

Antes de activarla, la versión nueva se completa en el servidor. Primero, se enlaza con lo compartido: por cada elemento de shared/, se borra lo que la versión traiga con ese nombre y se crea un enlace en su lugar:

en el servidor, dentro de la versión nueva
cd "$1/releases/$2"
for compartido in "$1"/shared/*; do
  nombre="${compartido##*/}"
  rm -rf -- "$nombre"
  ln -s -- "../../shared/$nombre" "$nombre"
done

Los enlaces son relativos, ../../shared/config.env, de modo que siguen funcionando aunque se mueva el directorio entero. Así, la configuración con las contraseñas nunca viaja con el código ni queda en el repositorio, y los archivos que suben los usuarios no se pierden en cada versión.

Después, si la versión trae un programa de preparación, por convención desplegar/preparar, se ejecuta: es donde cada proyecto instala sus dependencias, compila lo que haya que compilar en el servidor o prepara la base de datos. Todo esto ocurre en el directorio de la versión nueva, mientras la anterior sigue atendiendo sin enterarse. Si algo falla en este punto, el despliegue se detiene y no pasó nada visible.

Con las bases de datos hay que tener un cuidado especial. Un cambio en su estructura, como borrar una columna, no se deshace cambiando un enlace, y deja a la versión anterior sin lo que necesita. La práctica habitual es hacer los cambios en dos pasos: primero agregar lo nuevo, de forma que las dos versiones funcionen, y recién en un despliegue posterior, cuando ya no se va a volver atrás, quitar lo viejo.

7. El cambio atómico

El momento decisivo es cambiar current para que apunte a la versión nueva. Lo natural sería ln -sfn releases/NUEVA current, pero ese comando hace dos operaciones: borra el enlace viejo y crea el nuevo. Entre una y otra hay un instante, breve pero real, en el que current no existe, y una petición que llegue justo entonces falla.

La forma atómica, como la escritura de archivos del tema 24, es crear el enlace nuevo con otro nombre y renombrarlo encima del viejo. El renombrado lo hace el núcleo en una sola operación: cualquiera que mire current ve el viejo o el nuevo, nunca un hueco.

cambiar de versión sin un instante sin current
cd /srv/tienda
ln -sfn releases/20261015-103012 current.nuevo
mv -T current.nuevo current

La opción -T de mv es imprescindible. Sin ella, como current es un enlace a un directorio, mv entendería «mover current.nuevo dentro del directorio al que apunta current», y dejaría un enlace perdido dentro de la versión activa. Con -T, el destino se trata como un archivo más, que se reemplaza.

8. Reiniciar y comprobar

Cambiar el enlace no basta: el programa en ejecución sigue siendo el viejo hasta que se reinicia. Si la aplicación es un servicio de systemd cuyo ExecStart apunta a /srv/tienda/current/…, reiniciarlo carga la versión nueva. El usuario de despliegue no debería ser root, así que se le da permiso para ese único comando, con una regla de sudo:

/etc/sudoers.d/despliegue (se edita con visudo -f)
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart tienda

En el script se usa sudo -n, que falla enseguida en lugar de pedir una contraseña si la regla no existe. Después del reinicio llega la pregunta importante: ¿funciona? La respuesta más útil la da la propia aplicación, con una dirección de salud, como /salud, que responde con éxito solo si el programa arrancó y puede acceder a lo que necesita. curl la consulta, con reintentos, porque un programa recién reiniciado tarda unos segundos en estar listo, como en los bucles con espera del tema 15:

¿responde la versión nueva?
responde() {                           # ¿responde bien la URL? Hasta 10 intentos, cada 3 segundos
  [[ -z "$url" ]] && return 0
  local intento
  for intento in {1..10}; do
    curl -fsS --max-time 5 -o /dev/null -- "$url" 2> /dev/null && return 0
    sleep 3
  done
  return 1
}

-f hace que curl termine con error si el servidor responde con un código de error HTTP, como 500 o 503, y no solo si no hay conexión; --max-time limita cada intento. La comprobación se hace desde la máquina que despliega, por el mismo camino que usan las personas, y no desde dentro del servidor.

9. Volver atrás

Si la versión nueva no responde, la anterior sigue intacta en su directorio, y volver a ella es repetir el cambio atómico apuntando a ella y reiniciar: unos pocos segundos. El script lo hace solo: si la comprobación falla después de activar una versión, vuelve a la anterior, comprueba que responde, y termina con error para que la persona o el sistema de integración continua que lanzó el despliegue se entere. La versión fallida queda en el servidor, para poder examinarla.

La vuelta atrás también sirve a mano, cuando el problema aparece más tarde, por ejemplo una hora después, cuando los usuarios descubren un error que la comprobación de salud no detectaba. La acción volver del script activa la versión anterior a la activa. Como cada versión está completa, se puede volver dos o tres versiones atrás, mientras se conserven.

Lo que la vuelta atrás no deshace es lo que la versión nueva haya cambiado fuera de su directorio: la estructura de la base de datos, los archivos de shared/, los mensajes que haya enviado. Por eso la sección 6 recomendaba que los cambios en la base de datos sean compatibles con la versión anterior.

10. Un despliegue a la vez, y un registro

Dos personas que despliegan a la vez en el mismo servidor, o un despliegue automático que coincide con uno manual, pueden pisarse. El script toma un candado en el servidor antes de empezar, con mkdir, que crea el directorio o falla si ya existe, en una sola operación atómica, una de las alternativas a flock que vimos en el tema 27. Un trap lo libera al terminar, haya ido bien o mal. Si el script muere de forma abrupta y el candado queda, el siguiente despliegue lo informa y una persona decide si borrarlo.

Además, cada cambio de versión queda anotado en un registro en el servidor, despliegues.log, con la fecha, la versión y quién la activó. Cuando algo deja de funcionar, la primera pregunta siempre es «¿qué cambió?», y ese archivo la responde.

Finalmente, las versiones viejas se borran, para no llenar el disco, conservando un número fijo de las más recientes, y nunca la activa. Con --link-dest, cada versión ocupa solo lo que cambió, de modo que conservar cinco o diez cuesta poco.

11. Un script completo

desplegar
#!/usr/bin/env bash
#
# desplegar — instala versiones de una aplicación en un servidor por SSH,
# con cambio atómico y vuelta atrás automática si la nueva no responde.
#
# Uso: desplegar [-s SERVICIO] [-u URL] [-k N] SERVIDOR:RUTA subir DIRECTORIO
#      desplegar [-s SERVICIO] [-u URL] SERVIDOR:RUTA volver
#      desplegar SERVIDOR:RUTA versiones
#
#   -s  servicio de systemd que se reinicia después de cada cambio
#   -u  URL que debe responder bien después de cada cambio
#   -k  cuántas versiones conservar en el servidor (5)
#
# En el servidor:  RUTA/releases/VERSIÓN/  cada versión instalada
#                  RUTA/shared/            lo que comparten todas: configuración, datos
#                  RUTA/current            enlace simbólico a la versión activa
#
set -euo pipefail

prog="${0##*/}"
servicio="" url="" conservar=5
ssh_opciones=(-o BatchMode=yes -o ConnectTimeout=10)

uso() {
  echo "Uso: $prog [-s SERVICIO] [-u URL] [-k N] SERVIDOR:RUTA subir DIRECTORIO" >&2
  echo "     $prog [-s SERVICIO] [-u URL] SERVIDOR:RUTA volver" >&2
  echo "     $prog SERVIDOR:RUTA versiones" >&2
  exit 2
}
error() { echo "$prog: $*" >&2; exit 1; }
paso() { echo "==> $*"; }

remoto() {                             # remoto SCRIPT ARG...: ejecuta SCRIPT con bash en el servidor
  local script="$1"
  shift
  ssh "${ssh_opciones[@]}" "$servidor" "bash -s -- $(printf '%q ' "$@")" <<< "set -euo pipefail
shopt -s nullglob dotglob
$script"
}

# Activa la versión $2 en la ruta $1 y reinicia el servicio $3, si lo hay
CAMBIAR='
cd "$1"
[[ -d "releases/$2" ]] || { echo "no existe la versión $2" >&2; exit 1; }
ln -sfn "releases/$2" current.nuevo
mv -T current.nuevo current
if [[ -n "$3" ]]; then sudo -n systemctl restart "$3"; fi'

anotar() {                             # una línea en el registro de despliegues del servidor
  remoto 'echo "$(date "+%F %T") $2" >> "$1/despliegues.log"' "$ruta" "$1 (${USER:-$(id -un)})"
}

responde() {                           # ¿responde bien la URL? Hasta 10 intentos, cada 3 segundos
  [[ -z "$url" ]] && return 0
  local intento
  for intento in {1..10}; do
    curl -fsS --max-time 5 -o /dev/null -- "$url" 2> /dev/null && return 0
    sleep 3
  done
  return 1
}

liberar() {
  remoto 'rmdir -- "$1/.desplegando"' "$ruta" || true
}

subir() {
  local dir="${1%/}" version anterior
  [[ -d "$dir" ]] || error "no existe el directorio: $dir"
  version=$(date +%Y%m%d-%H%M%S)

  paso "Preparando $servidor:$ruta"
  anterior=$(remoto '
    mkdir -p "$1/releases" "$1/shared"
    mkdir "$1/.desplegando" 2> /dev/null || { echo "ya hay un despliegue en marcha en $1" >&2; exit 1; }
    if [[ -L "$1/current" ]]; then basename "$(readlink "$1/current")"; fi' "$ruta")
  trap liberar EXIT

  paso "Copiando $dir como versión $version${anterior:+; la activa es $anterior}"
  local opciones=(-az --delete -e "ssh ${ssh_opciones[*]}")
  [[ -n "$anterior" ]] && opciones+=(--link-dest="$ruta/releases/$anterior/")
  rsync "${opciones[@]}" -- "$dir/" "$servidor:$ruta/releases/$version/"

  paso "Enlazando lo compartido y preparando la versión"
  remoto '
    cd "$1/releases/$2"
    for compartido in "$1"/shared/*; do
      nombre="${compartido##*/}"
      rm -rf -- "$nombre"
      ln -s -- "../../shared/$nombre" "$nombre"
    done
    if [[ -x desplegar/preparar ]]; then ./desplegar/preparar; fi' "$ruta" "$version"

  paso "Activando $version"
  remoto "$CAMBIAR" "$ruta" "$version" "$servicio"
  if responde; then
    anotar "activada $version"
    paso "Versión $version activa${url:+; $url responde}"
  else
    [[ -n "$anterior" ]] || error "$url no responde, y no hay una versión anterior a la que volver"
    paso "$url no responde: volviendo a $anterior"
    remoto "$CAMBIAR" "$ruta" "$anterior" "$servicio"
    anotar "falló $version; se volvió a $anterior"
    responde || error "tampoco responde con $anterior: hay que intervenir a mano"
    error "la versión $version falló; sigue activa $anterior"
  fi

  remoto '
    cd "$1/releases"
    versiones=(*)
    activa=$(basename "$(readlink ../current)")
    for (( i = 0; i < ${#versiones[@]} - $2; i++ )); do
      [[ "${versiones[i]}" == "$activa" ]] && continue
      rm -rf -- "${versiones[i]}"
      echo "    borrada la versión ${versiones[i]}"
    done' "$ruta" "$conservar"
}

volver() {
  local destino
  destino=$(remoto '
    cd "$1/releases"
    activa=$(basename "$(readlink ../current)")
    anterior=""
    for v in *; do
      [[ "$v" == "$activa" ]] && break
      anterior="$v"
    done
    [[ -n "$anterior" ]] || { echo "no hay una versión anterior a $activa" >&2; exit 1; }
    echo "$anterior"' "$ruta")

  paso "Volviendo a $destino"
  remoto "$CAMBIAR" "$ruta" "$destino" "$servicio"
  responde || error "${url} no responde con $destino"
  anotar "vuelta atrás a $destino"
  paso "Versión $destino activa"
}

versiones() {
  remoto '
    cd "$1/releases"
    activa=$(readlink ../current || true)
    for v in *; do
      if [[ "$v" == "${activa##*/}" ]]; then echo "$v  ← activa"; else echo "$v"; fi
    done' "$ruta"
}

while getopts ":s:u:k:" opcion; do
  case "$opcion" in
    s) servicio="$OPTARG" ;;
    u) url="$OPTARG" ;;
    k) conservar="$OPTARG" ;;
    *) uso ;;
  esac
done
shift $(( OPTIND - 1 ))
(( $# >= 2 )) || uso
[[ "$1" =~ ^([^:]+):(/.+)$ ]] || error "el destino debe tener la forma SERVIDOR:/ruta/absoluta"
servidor="${BASH_REMATCH[1]}" ruta="${BASH_REMATCH[2]%/}"
[[ "$conservar" =~ ^[1-9][0-9]*$ ]] || error "-k debe ser un número mayor que cero"
[[ -z "$servicio" || "$servicio" =~ ^[A-Za-z0-9@._-]+$ ]] || error "nombre de servicio no válido: $servicio"
accion="$2"
shift 2

case "$accion" in
  subir)     (( $# == 1 )) || uso; subir "$1" ;;
  volver)    (( $# == 0 )) || uso; volver ;;
  versiones) (( $# == 0 )) || uso; versiones ;;
  *) uso ;;
esac
terminal
$ desplegar -s tienda -u https://tienda.example.com/salud web1:/srv/tienda subir build/paquete/tienda
==> Preparando web1:/srv/tienda
==> Copiando build/paquete/tienda como versión 20261015-103012; la activa es 20261014-163210
==> Enlazando lo compartido y preparando la versión
==> Activando 20261015-103012
==> Versión 20261015-103012 activa; https://tienda.example.com/salud responde
    borrada la versión 20261009-120455
$ desplegar -s tienda -u https://tienda.example.com/salud web1:/srv/tienda subir build/paquete/tienda
==> Preparando web1:/srv/tienda
==> Copiando build/paquete/tienda como versión 20261015-114540; la activa es 20261015-103012
==> Enlazando lo compartido y preparando la versión
==> Activando 20261015-114540
==> https://tienda.example.com/salud no responde: volviendo a 20261015-103012
desplegar: la versión 20261015-114540 falló; sigue activa 20261015-103012
$ desplegar web1:/srv/tienda versiones
20261013-091500
20261014-163210
20261015-103012  ← activa
20261015-114540
$ ssh web1 cat /srv/tienda/despliegues.log
2026-10-14 16:32:31 activada 20261014-163210 (ana)
2026-10-15 10:30:44 activada 20261015-103012 (ana)
2026-10-15 11:46:19 falló 20261015-114540; se volvió a 20261015-103012 (ana)

El script se probó en Linux, con los enlaces, rsync y mv -T reales y ssh reemplazado por un intermediario que ejecuta los comandos en la misma máquina. Antes de usarlo con un servidor real, conviene probarlo contra uno de prueba, o una máquina virtual, con la misma estructura.

12. Errores frecuentes

EscribisteQué pasaCorrección
Copiar la versión nueva encima de la activaDurante la copia, una mezcla de versiones; si falla, queda así, y no hay vuelta atrás.Una versión por directorio y un enlace current.
ssh servidor rm -rf "$dir"La shell remota vuelve a dividir el valor: con espacios, borra otras cosas.printf '%q', o un script con bash -s y argumentos.
Comillas dobles en el script remoto: remoto "cd $1"El $1 lo expande la shell local, no la remota.El script remoto entre comillas simples.
ssh sin BatchMode=yesSi falla la clave, pide una contraseña y el despliegue automático se cuelga.-o BatchMode=yes -o ConnectTimeout=10
ln -sfn releases/X currentHay un instante sin current: peticiones que fallan.Enlace provisional y mv -T.
mv current.nuevo current sin -TSe mueve el enlace dentro del directorio de la versión activa.mv -T
La configuración del servidor dentro del paqueteCada despliegue la pisa, y las contraseñas viajan con el código.En shared/, con un enlace desde cada versión.
Desplegar como rootCualquier error del script tiene todos los permisos.Un usuario de despliegue y una regla de sudo para el reinicio.
Dar el despliegue por bueno porque el reinicio funcionóEl servicio arranca pero no atiende, y nadie lo nota.Una comprobación de salud, con curl -f y reintentos.
Borrar una columna de la base de datos en el mismo despliegueLa vuelta atrás deja a la versión anterior sin lo que necesita.Cambios compatibles, en dos despliegues.

13. Resumen

14. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué problemas tiene copiar la versión nueva encima de la que está funcionando?

Durante la copia, el servidor tiene una mezcla de archivos de las dos versiones; si la copia se interrumpe, la mezcla queda; no hay forma rápida de volver a la anterior; y se pueden pisar la configuración y los datos del servidor.

2. ¿Para qué sirven releases/, shared/ y current?

releases/ guarda cada versión completa en su propio directorio; shared/, lo que debe sobrevivir a las versiones, como la configuración y los archivos de los usuarios; current es un enlace simbólico a la versión activa, que es la ruta que usan el servicio y el servidor web.

3. ¿Por qué ssh web1 ls "$archivo" falla si el nombre tiene espacios?

Porque ssh une los argumentos en una sola línea y la shell del servidor la vuelve a dividir en palabras. Las comillas locales ya se usaron en el equipo local. Se protege con printf '%q'.

4. ¿Qué hace ssh servidor "bash -s -- arg1 arg2" <<< "$script"?

Ejecuta en el servidor un bash que lee su programa de la entrada estándar, el contenido de $script, con arg1 y arg2 como sus argumentos $1 y $2.

5. ¿Para qué sirve --link-dest en un despliegue?

Para que los archivos que no cambiaron respecto de la versión activa se enlacen con enlaces duros en lugar de transferirse. La copia es mucho más rápida, y cada versión ocupa solo lo que cambió.

6. ¿Por qué ln -sfn releases/X current no es atómico, y cómo se logra que lo sea?

Porque borra el enlace viejo y después crea el nuevo, y entre las dos operaciones current no existe. Se crea el enlace con otro nombre y se renombra encima con mv -T, que el núcleo hace en una sola operación.

7. ¿Qué pasa si se omite -T en mv -T current.nuevo current?

Como current apunta a un directorio, mv mueve current.nuevo dentro de ese directorio, y current sigue apuntando a la versión anterior.

8. ¿Por qué la comprobación de salud usa curl -f y varios intentos?

-f hace que un código de error HTTP, como 500 o 503, cuente como fallo, y no solo la falta de conexión. Los intentos dan tiempo a que el programa recién reiniciado termine de arrancar.

9. ¿Por qué el script termina con error aunque la vuelta atrás automática haya funcionado?

Porque el despliegue pedido no se hizo: la versión nueva falló. Terminar con error avisa a la persona o al sistema que lo lanzó, que tiene que investigar la versión fallida.

10. ¿Qué no deshace volver a la versión anterior?

Los cambios hechos fuera del directorio de la versión: la estructura de la base de datos, los archivos de shared/, los efectos externos como correos enviados. Por eso los cambios de base de datos se hacen compatibles con la versión anterior.

Para practicar

  1. Configura en ~/.ssh/config un servidor de prueba con una clave dedicada, y comprueba que ssh -o BatchMode=yes servidor true funciona sin preguntar nada.
  2. Crea un archivo con espacios en el nombre en el servidor y comprueba la diferencia entre ssh servidor ls "$nombre" y la versión con printf '%q'.
  3. En un directorio local, arma la estructura releases/, shared/ y current con dos versiones, y cambia de una a otra con ln -sfn y mv -T. Prueba qué pasa con mv sin -T.
  4. Si no tienes un servidor, prueba desplegar con localhost como servidor, con el servicio SSH local en marcha, y una ruta como /tmp/tienda.
  5. Haz que la comprobación de salud falle, por ejemplo con una URL inexistente, y verifica que el script vuelve a la versión anterior y lo anota en despliegues.log.
  6. Agrega a desplegar una opción -n que muestre qué haría: qué versión se activaría, qué se copiaría con rsync -n -i y qué versiones se borrarían, sin hacer nada.