Saltar al contenido
Ende Sun
Volver al blog

DevOps y seguridad

Despliegues atómicos en un hosting compartido con GitHub Actions

7 min de lectura

Despliegues atómicos en un hosting compartido con GitHub Actions

Esta web es una aplicación Laravel 12 con panel de administración en Filament, y corre en un hosting compartido corriente: acceso SSH, sin root, sin contenedores y sin servicio de despliegue gestionado. Durante un tiempo, desplegar consistía en comprimir el proyecto y subirlo a mano. Quería tres cosas: que un push a master llegara solo a producción, que nadie viera nunca una versión copiada a medias y que una versión rota se deshiciera sola sin que yo estuviera delante.

Las restricciones

El hosting compartido condiciona el diseño más que cualquier herramienta:

  • En el servidor, exec() y symlink() de PHP están desactivadas, así que php artisan storage:link no puede funcionar. Todos los enlaces se crean desde la shell.
  • La raíz web es una carpeta fija que gestiona el panel del hosting. La aplicación vive fuera de ella y el punto de entrada público se limita a cargar el index.php de la aplicación a través de una ruta estable.
  • No quiero depender de Node.js en producción, así que los assets del frontend tienen que llegar ya compilados.
  • La clave que use la integración continua no puede dar una shell de uso general en el servidor.

El pipeline

El workflow de GitHub Actions tiene dos trabajos. test se ejecuta en cada push y en cada pull request: instala las dependencias de Composer con PHP 8.2, crea un .env a partir del de ejemplo y lanza toda la batería de tests. deploy depende de test y solo se ejecuta en master:

  1. pnpm install --frozen-lockfile y pnpm run build, para que los assets compilados salgan de la CI.
  2. Empaquetado: git archive del commit exacto, más la carpeta generada public/build, comprimido con gzip.
  3. Una única llamada SSH que envía el paquete al servidor por la entrada estándar.
git archive --format=tar -o release.tar HEAD
tar -rf release.tar public/build
gzip release.tar

ssh -i ~/.ssh/deploy deploy@server "deploy ${GITHUB_SHA::12}" < release.tar.gz

Antes de conectar, el trabajo comprueba que la clave privada guardada en los secretos es realmente una clave válida, y la clave del servidor sale de un secreto known_hosts fijado de antemano, en vez de aceptarse a ciegas la primera vez.

Una clave que solo sirve para desplegar

La clave de despliegue está autorizada en el servidor con un comando forzado:

restrict,command="~/bin/site-receive" ssh-ed25519 AAAA... github-actions

restrict desactiva la redirección de puertos, la del agente y la asignación de terminal. command= hace que, pida lo que pida el cliente, el servidor ejecute el script de recepción; el comando solicitado solo llega como la variable SSH_ORIGINAL_COMMAND. El script acepta una única forma, deploy <sha-del-commit>, y rechaza todo lo demás:

id=$(printf '%s' "${SSH_ORIGINAL_COMMAND:-}" | sed -nE 's/^deploy ([0-9a-f]{7,40})$/\1/p')
[ -n "$id" ] || { echo "uso: deploy <sha>" >&2; exit 2; }

REL="$HOME/apps/site-releases/$(date +%Y%m%d-%H%M%S)-$id"
mkdir -p "$REL"
tar -xzf - -C "$REL"
exec bash "$REL/deploy/activate.sh" "$REL"

Si la clave se filtrara, lo peor que podría hacer es desplegar un paquete, que es justo para lo que existe.

Versiones, estado compartido y un cambio de enlace

Cada despliegue se descomprime en su propia carpeta. Lo que tiene que sobrevivir entre versiones, el fichero .env y storage/, vive en una carpeta compartida, y la aplicación activa es un enlace simbólico:

~/apps/site            -> ~/apps/site-releases/20261023-235857-abc1234
~/apps/site-shared/    .env, storage/
~/apps/site-releases/  una carpeta por despliegue

El script de activación deja la carpeta nueva completamente preparada antes de que nadie pueda llegar a ella: enlaza .env y storage, ejecuta composer install --no-dev --optimize-autoloader, enlaza public/storage con las subidas compartidas, lanza las migraciones y la sincronización de contenido, y cachea configuración, rutas y vistas. Solo entonces cambia de versión:

ln -s "$REL" "$APP.next" && mv -Tf "$APP.next" "$APP"

En este paso evito ln -sfn porque borra el enlace antiguo y después crea el nuevo, lo que deja un instante sin enlace. Crear un enlace temporal y renombrarlo encima del antiguo se apoya en rename(), que sustituye el enlace en una sola operación: una petición ve la versión antigua o la nueva, nunca nada.

Después del cambio, el script copia el .htaccess de la aplicación a la raíz web y enlaza cualquier fichero nuevo de public/ que la raíz web todavía no tenga.

Comprobaciones de salud y vuelta atrás automática

Dos segundos después del cambio, el script pide las portadas en español, inglés y chino, el login del panel y el sitemap, y espera un 200 de cada una. Si alguna responde otra cosa, vuelve a apuntar el enlace a la versión anterior y termina con error, así que la ejecución de GitHub Actions se pone en rojo. Si todas pasan, borra versiones antiguas: conserva las tres más recientes y nunca toca ni la actual ni la anterior.

La vuelta atrás que se ganó el sueldo

El primer despliegue de una refactorización grande del contenido es el motivo por el que me fío de este sistema. Esa versión introducía categorías de proyectos y contenido traducible. La CI estaba en verde. En el servidor, todas las migraciones se ejecutaron sin errores. Y entonces la comprobación de /es recibió un 500.

El código nuevo esperaba que existiera al menos una categoría de proyecto, y producción todavía no tenía ninguna: las categorías las carga un comando de Artisan aparte que lee el contenido preparado, y yo había previsto ejecutarlo a mano después del despliegue. La comprobación detectó el error, el script volvió a la versión anterior y la ejecución falló con el aviso de que la versión nueva no respondía bien y de que las migraciones no se habían deshecho. La versión rota solo estuvo activa los pocos segundos entre el cambio y las comprobaciones.

La solución no fue prometerme más cuidado la próxima vez, sino cambiar el orden: la sincronización de contenido se ejecuta ahora dentro de la activación, justo después de las migraciones y antes de las comprobaciones. La lección es sencilla: todo lo que el código nuevo necesita para pintar la página tiene que ocurrir antes de las comprobaciones, nunca como un paso manual posterior.

La advertencia: las migraciones no se deshacen

La vuelta atrás solo mueve código. La base de datos se queda migrada, y el propio script lo dice en su mensaje de error. Esto solo es seguro si la versión anterior sigue funcionando con el esquema nuevo, y como las migraciones se ejecutan antes del cambio, mientras el código antiguo todavía atiende peticiones, esa condición tiene que cumplirse de todos modos.

En el incidente anterior se cumplía porque todos los cambios eran aditivos: tablas nuevas y columnas nuevas que admiten nulos. Esa es la regla que sigo ahora. Primero se añade; renombrar o eliminar va en un despliegue posterior, cuando ningún código en marcha depende ya de la forma antigua. Para cualquier cosa que reescriba datos existentes, hago una copia de la base de datos antes del push. No automaticé migrate:rollback a propósito: deshacer cambios de esquema sobre datos de producción es una decisión que quiero tomar yo.

En resumen

Nada de esto necesita infraestructura especial. Es un fichero de workflow, tres scripts de shell, una clave SSH restringida y una estructura de carpetas. A cambio, cada despliegue queda o completamente activo o completamente revertido, y si algo falla me entero por una ejecución en rojo de la CI y no por un visitante.