---
title: 'Zero-downtime deployment para Laravel: releases atómicos, migraciones y colas | DevSense'
description: 'Cómo desplegar Laravel sin tiempo de inactividad: releases mediante symlink, OPcache y realpath, compilación de assets en CI, migraciones seguras en PostgreSQL con el patrón expand/contract y reinicio suave de las colas.'
faq:
    - { question: '¿Por qué php artisan down no sirve para un despliegue sin tiempo de inactividad?', answer: 'El comando pone la aplicación en modo de mantenimiento y devuelve 503 a todos los usuarios mientras se instalan las dependencias, se ejecutan las migraciones y se calientan las cachés. Es un tiempo de inactividad honesto, pero planificado. Además, si el script falla después de down y antes de up, el sitio queda inaccesible hasta que alguien intervenga manualmente.' }
    - { question: '¿Qué es un release atómico mediante symlink?', answer: 'Cada versión del código se construye en una carpeta independiente releases/<id>, y el servidor web apunta al enlace simbólico current. Cuando la nueva versión está completamente lista (dependencias, cachés, assets), el enlace se cambia con una única operación atómica mv -T. Las peticiones van a la versión antigua o a la nueva, pero nunca a una actualizada a medias.' }
    - { question: '¿Cómo ejecutar las migraciones para que el código antiguo y el nuevo funcionen a la vez?', answer: 'Con el patrón expand/contract: primero solo añada (nuevas columnas, tablas, índices CONCURRENTLY), después despliegue código que escriba en ambos sitios y solo en el siguiente release elimine lo antiguo. Renombrar o eliminar una columna en el mismo release en el que cambia el código rompe las consultas de los workers antiguos.' }
    - { question: '¿Cómo reiniciar los workers de las colas sin perder trabajos?', answer: 'Con el comando php artisan queue:restart (o horizon:terminate para Horizon). Los workers terminan el trabajo en curso, finalizan y Supervisor los vuelve a levantar ya con el código nuevo. Es importante que los trabajos serializados encolados por el código antiguo sigan siendo compatibles con el nuevo.' }
published: '2026-09-28'
---
# Zero-downtime deployment para Laravel: releases atómicos, migraciones y colas

Un despliegue que «normalmente funciona» es más peligroso que uno que falla de inmediato. Hace poco nuestro propio despliegue falló en `git fetch`: habían caducado las credenciales del repositorio privado. El sitio sobrevivió solo porque `git fetch` estaba **antes** de `php artisan down`. Si hubiera estado un par de líneas más abajo, el sitio se habría quedado atascado en modo de mantenimiento toda la noche. La mayoría de los proyectos Laravel se despliegan precisamente así: `down`, `git pull`, `composer install`, `migrate`, `up`. Funciona hasta el primer fallo a mitad de camino y, cada vez, muestra un 503 a los usuarios.

**Guías relacionadas:** [Entorno, CI y despliegue con Sail](../tools/sail-env-deploy) · [Comparativa de colas de mensajes](message-queues-compared) · [Observabilidad y monitorización](observability-monitoring-laravel)

## Contenido

* [De dónde sale el tiempo de inactividad en un despliegue](#where-downtime-comes-from)
* [La idea principal: preparar al lado, cambiar de forma atómica](#core-idea)
* [Releases atómicos: releases, shared y current](#atomic-releases)
* [PHP-FPM, OPcache y realpath: por qué el symlink «no cambia»](#opcache)
* [Assets: compilación en CI y pestañas antiguas](#assets)
* [Migraciones sin bloqueos: expand/contract](#migrations)
* [Colas y scheduler: reinicio suave](#queues)
* [Health check y rollback](#health-and-rollback)
* [Dónde deja de funcionar este enfoque](#limitations)
* [Errores frecuentes](#common-mistakes)
* [Checklist](#checklist)
* [Cuestionario de autoevaluación](#self-test-quiz)

---

<a id="where-downtime-comes-from"></a>
## De dónde sale el tiempo de inactividad en un despliegue

El tiempo de inactividad rara vez se ve como «el servidor está apagado». Lo más habitual son unos segundos o minutos en los que la aplicación se encuentra en un estado intermedio:

* **`composer install` sobre el código en ejecución.** Mientras se sobrescriben los paquetes de `vendor/`, las peticiones concurrentes cargan una mezcla de clases antiguas y nuevas. El resultado: `Class not found` o errores fatales por firmas incompatibles.
* **Caché de configuración de otra versión.** `bootstrap/cache/config.php` se generó con el código antiguo, pero las rutas o los service providers ya son nuevos. Hasta que no se ejecuta `php artisan optimize`, la aplicación vive desincronizada.
* **Migraciones con bloqueos.** Un `ALTER TABLE` sobre una tabla grande adquiere un bloqueo exclusivo y todas las consultas a esa tabla se ponen en cola. Para el usuario, parece que el sitio se ha colgado.
* **Workers con el código antiguo.** Supervisor mantiene procesos `queue:work` cargados antes del despliegue. Siguen ejecutando trabajos con las clases antiguas contra el nuevo esquema de la BD.
* **Assets de otra versión.** `public/build/manifest.json` se actualizó antes o después que el código, y la página hace referencia a CSS y JS que todavía (o ya) no existen.

El modo de mantenimiento enmascara todos estos problemas mostrando un 503. Pero no es una solución, sino una admisión honesta del tiempo de inactividad.

---

<a id="core-idea"></a>
## La idea principal: preparar al lado, cambiar de forma atómica

**Un despliegue sin tiempo de inactividad es aquel en el que la nueva versión se construye por completo junto a la antigua, el cambio ocupa una sola operación atómica y el esquema de la BD es compatible en todo momento tanto con el código antiguo como con el nuevo.**

De esta afirmación se derivan tres reglas:

1. No modificar nada «en el sitio» en la carpeta desde la que se están sirviendo las peticiones.
2. El cambio es una única operación sin estado intermedio.
3. Las migraciones y los trabajos de las colas se diseñan para que dos versiones del código puedan convivir a la vez.

---

<a id="atomic-releases"></a>
## Releases atómicos: releases, shared y current

Estructura en el servidor:

```
/var/www/app/
├── releases/
│   ├── 20260927-2101-1d94729/
│   └── 20260928-1015-286fc47/   ← nuevo release
├── shared/
│   ├── .env
│   └── storage/                  ← logs, subidas, sesiones
└── current -> releases/20260927-2101-1d94729
```

Cada release es una carpeta independiente con el código completo. Todo lo que debe sobrevivir entre releases (`.env`, `storage/`) vive en `shared/` y se enlaza mediante symlinks. El servidor web apunta a `current/public`.

```bash
#!/usr/bin/env bash
# deploy/release.sh
set -euo pipefail

APP=/var/www/app

# 1. El código del nuevo release va a una carpeta aparte; el sitio actual no se toca
git -C "$APP/repo" fetch origin
RELEASE="$APP/releases/$(date +%Y%m%d-%H%M)-$(git -C "$APP/repo" rev-parse --short origin/prod)"
mkdir -p "$RELEASE"
git -C "$APP/repo" archive origin/prod | tar -x -C "$RELEASE"

# 2. Archivos compartidos
ln -s "$APP/shared/.env" "$RELEASE/.env"
rm -rf "$RELEASE/storage" && ln -s "$APP/shared/storage" "$RELEASE/storage"

# 3. Las dependencias y las cachés se generan antes del cambio
cd "$RELEASE"
composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction
php artisan optimize

# 4. Migraciones: solo las compatibles con el código actual (ver expand/contract)
php artisan migrate --force

# 5. Cambio atómico: rename(2) no tiene estado intermedio
ln -s "$RELEASE" "$APP/current_tmp"
mv -Tf "$APP/current_tmp" "$APP/current"

# 6. Limpieza de OPcache y reinicio suave de los workers
sudo systemctl reload php8.5-fpm
php artisan queue:restart

# 7. Conservamos los últimos 5 releases para el rollback
ls -1dt "$APP"/releases/* | tail -n +6 | xargs -r rm -rf
```

> [!IMPORTANT]
> **`ln -sfn` no es atómico.** Por debajo son dos llamadas al sistema: eliminar el enlace antiguo y crear el nuevo. Entre ambas hay un instante en el que `current` no existe y nginx devuelve 404. Solo `rename(2)` funciona de forma atómica, es decir, `mv -T` sobre el enlace existente.

Si no quiere configurarlo a mano, Deployer (`deployer/deployer`) y Laravel Envoyer implementan este mismo enfoque.

---

<a id="opcache"></a>
## PHP-FPM, OPcache y realpath: por qué el symlink «no cambia»

Una queja habitual: «hemos cambiado el enlace, pero el sitio muestra la versión antigua». La causa son dos cachés:

* **realpath cache** en PHP recuerda a qué se resuelve la ruta `/var/www/app/current/...`;
* **OPcache** guarda los archivos compilados según la ruta resuelta. En producción suele usarse `opcache.validate_timestamps=0`, y PHP ni siquiera comprueba si los archivos han cambiado.

La solución tiene dos partes. Primero, nginx debe pasar a PHP la ruta ya resuelta, no la ruta a través del symlink:

```nginx
# /etc/nginx/sites-available/app.conf
root /var/www/app/current/public;

location ~ \.php$ {
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
    fastcgi_param DOCUMENT_ROOT   $realpath_root;
    fastcgi_pass unix:/run/php/php8.5-fpm.sock;
}
```

`$realpath_root` resuelve el symlink en cada petición. Las peticiones nuevas llegan con la ruta `releases/20260928-...` y OPcache las compila como archivos nuevos. Después, tras el cambio, hace falta un `reload` de PHP-FPM (es suave: las peticiones en curso terminan) o `opcache_reset()` mediante `cachetool` para liberar la memoria ocupada por el release antiguo.

---

<a id="assets"></a>
## Assets: compilación en CI y pestañas antiguas

Compilar el frontend en el servidor de producción supone carga y una dependencia (Node.js) innecesarias. Es mejor compilarlo en CI y subirlo a la carpeta del **nuevo release** antes del cambio:

```yaml
# .github/workflows/deploy-prod.yml (fragmento)
- name: Build assets
  run: npm ci && npm run build

- name: Upload assets into the new release
  run: rsync -az --delete public/build/ "$SERVER:$RELEASE/public/build/"
```

Vite añade un hash a los nombres de archivo (`app-TDO2OnyO.js`) y `manifest.json` vive dentro del release. Por eso el código antiguo hace referencia a los archivos antiguos, el nuevo a los nuevos, y el cambio del symlink los sustituye junto con el código PHP.

Queda un detalle: el usuario puede tener abierta una pestaña con la página antigua que, un minuto después, solicitará un chunk con carga diferida. Si ya no existe, se producirá un error de carga del módulo. Por eso conviene conservar varios releases anteriores en disco o subir los assets a un bucket CDN común sin eliminar los hashes antiguos.

---

<a id="migrations"></a>
## Migraciones sin bloqueos: expand/contract

Las migraciones se ejecutan **antes** del cambio, así que durante un tiempo el código antiguo trabaja con el esquema nuevo. De ahí la regla: cada migración debe ser compatible con el código del release anterior. Para lograrlo, los cambios de esquema se dividen en fases.

**Ejemplo: renombrar `users.name` a `users.full_name`.**

| Release | Esquema | Código |
|---|---|---|
| 1. Expand | Añadir `full_name` (nullable) | Escribe en ambas columnas, lee `name` |
| 2. Migrate | Un trabajo en segundo plano copia los datos | Lee `full_name`, escribe en ambas |
| 3. Contract | Eliminar `name` | Trabaja solo con `full_name` |

No se puede hacer en un único release con `renameColumn`: los workers antiguos fallarán al encontrarse con una columna inexistente.

En PostgreSQL también importa **qué bloqueos adquiere la migración**:

```php
// database/migrations/2026_09_28_000000_add_status_index_to_orders.php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    // CREATE INDEX CONCURRENTLY no se puede ejecutar dentro de una transacción
    public $withinTransaction = false;

    public function up(): void
    {
        DB::statement('SET lock_timeout = \'5s\'');
        DB::statement('CREATE INDEX CONCURRENTLY IF NOT EXISTS orders_status_idx ON orders (status)');
    }

    public function down(): void
    {
        DB::statement('DROP INDEX CONCURRENTLY IF EXISTS orders_status_idx');
    }
};
```

Lo importante aquí:

* **`CONCURRENTLY`** construye el índice sin bloquear la escritura. Un `$table->index()` normal bloquea la tabla durante toda la construcción.
* **`lock_timeout`** impide que la migración espere indefinidamente un bloqueo. De lo contrario, detrás de ella se acumulará una cola con todas las demás consultas a la tabla. Es mejor fallar a los 5 segundos y reintentar que colgar el sitio.
* **Una columna con `DEFAULT`** se añade al instante en PostgreSQL 11+, pero `NOT NULL` sobre una columna existente requiere un escaneo completo. El camino seguro: `ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID`, después `VALIDATE CONSTRAINT` y luego `SET NOT NULL` (desde PostgreSQL 12 utiliza la restricción ya validada y no escanea la tabla).

> [!WARNING]
> **El rollback del código no revierte el esquema.** `migrate:rollback` en producción con datos casi siempre es peor que una nueva migración «hacia delante». Es precisamente expand/contract lo que hace seguro el rollback del código: la versión anterior sigue funcionando con el esquema ampliado.

---

<a id="queues"></a>
## Colas y scheduler: reinicio suave

Un worker `queue:work` es un proceso de larga duración que ha cargado el código una sola vez. Después de cambiar de release, seguirá ejecutando trabajos con el código antiguo hasta que se reinicie.

```bash
php artisan queue:restart      # para queue:work bajo Supervisor
php artisan horizon:terminate  # para Horizon
```

Ambos comandos son suaves: el worker termina el trabajo en curso y finaliza, y Supervisor levanta un proceso nuevo ya desde `current`. No se pierden trabajos.

El segundo problema es la **compatibilidad de los trabajos**. Un trabajo encolado por el código antiguo está serializado con su conjunto de propiedades. Si el código nuevo ha renombrado una propiedad o ha añadido un argumento obligatorio al constructor, la deserialización fallará:

```php
// app/Jobs/SendInvoice.php
final class SendInvoice implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public readonly int $orderId,
        // Campo nuevo, con valor por defecto para que los trabajos
        // del release antiguo se deserialicen correctamente
        public readonly string $locale = 'en',
    ) {}
}
```

El scheduler (`schedule:run` desde cron) no necesita reinicio: cron lanza cada minuto un proceso nuevo que ya ve el `current` actualizado.

---

<a id="health-and-rollback"></a>
## Health check y rollback

El cambio no termina el despliegue. Hay que asegurarse de que la nueva versión está viva:

```bash
# Tras el cambio: ruta de health de Laravel 11+ (bootstrap/app.php → health: '/up')
for i in {1..10}; do
  curl -fsS --max-time 5 https://example.com/up > /dev/null && exit 0
  sleep 3
done

# No ha levantado: volvemos al release anterior con el mismo cambio atómico
PREV=$(ls -1dt /var/www/app/releases/* | sed -n 2p)
ln -s "$PREV" /var/www/app/current_tmp && mv -Tf /var/www/app/current_tmp /var/www/app/current
sudo systemctl reload php8.5-fpm
exit 1
```

El rollback tarda segundos porque el release anterior está completo en disco, con `vendor/` y los assets compilados. El esquema de la BD, en cambio, sigue siendo el nuevo, y precisamente por eso debe ser retrocompatible.

---

<a id="limitations"></a>
## Dónde deja de funcionar este enfoque

* **Un solo servidor no es tolerancia a fallos.** El symlink elimina el tiempo de inactividad durante el despliegue, pero no cuando cae el servidor. Para eso hacen falta varios nodos detrás de un balanceador y un despliegue rolling o blue-green.
* **Algunas migraciones no se pueden hacer en línea.** Cambiar el tipo de una columna reescribiendo la tabla, particionar una tabla grande, mover datos entre bases de datos. A veces una ventana de mantenimiento honesta por la noche sale más barata que un esquema expand/contract de una semana.
* **Sesiones y caché.** Si la nueva versión cambia el formato de los datos de sesión o las claves de caché, los usuarios con sesiones antiguas recibirán errores. Versione los prefijos de caché y no cambie el formato de sesión sin retrocompatibilidad.
* **Complejidad.** Para un pet project con diez visitantes por hora, `down`/`up` con un ERR-trap puede ser un compromiso razonable. Lo importante es que sea una decisión consciente, y que un fallo del script no deje el sitio en maintenance.

---

<a id="common-mistakes"></a>
## Errores frecuentes

**1. `git pull` y `composer install` en la carpeta desde la que se sirven las peticiones.**
Las peticiones concurrentes ven una mezcla de versiones. Construya el release en una carpeta aparte.

**2. `ln -sfn` para hacer el cambio.**
No es atómico. Use `ln -s` hacia un nombre temporal y `mv -T`.

**3. `SCRIPT_FILENAME $document_root...` en nginx.**
PHP cachea la ruta a través del symlink y sigue sirviendo el código antiguo. Use `$realpath_root`.

**4. Renombrar una columna en el mismo release que el código.**
Los workers antiguos y las peticiones en curso en el momento del cambio fallarán. Divídalo en expand/contract.

**5. `$table->index()` sobre una tabla grande.**
Bloquea la escritura durante toda la construcción. Use `CREATE INDEX CONCURRENTLY` y `$withinTransaction = false`.

**6. Olvidar `queue:restart`.**
Los workers pasan semanas ejecutando trabajos con el código antiguo contra el esquema nuevo.

**7. `php artisan down` sin garantía de `up`.**
Si el script falla a mitad de camino, el sitio se queda en modo de mantenimiento. Como mínimo: `trap 'php artisan up' ERR`.

---

<a id="checklist"></a>
## Checklist

1. El código, las dependencias, las cachés y los assets del nuevo release se preparan en una carpeta aparte.
2. `.env` y `storage/` están en `shared/`.
3. El cambio es un `mv -T` del symlink, y nginx usa `$realpath_root`.
4. Tras el cambio: `reload` de PHP-FPM y `queue:restart` (o `horizon:terminate`).
5. Cada migración es compatible con el código del release anterior; los índices, `CONCURRENTLY`; hay un `lock_timeout` configurado.
6. Los nuevos argumentos de los trabajos tienen valores por defecto.
7. Tras el cambio se comprueba `/up` y, si falla, se hace rollback automático al release anterior.
8. Se conservan en disco los últimos releases.

---

## Resumen

Un despliegue sin tiempo de inactividad no es una herramienta, sino una propiedad del sistema: el código sabe convivir en dos versiones a la vez. Los symlinks, `$realpath_root` y `queue:restart` resuelven la mecánica del cambio. Pero el verdadero trabajo está en la disciplina de las migraciones y la compatibilidad de los trabajos, que convierte el rollback en una operación aburrida de un par de segundos y no en un incidente nocturno.

---

<a id="self-test-quiz"></a>
## Cuestionario de autoevaluación

### Pregunta 1: ¿Por qué `ln -sfn new current` no sirve para cambiar de release de forma atómica?
- A) El comando no funciona con rutas absolutas.
- B) Ejecuta dos llamadas al sistema (unlink y symlink), y entre ellas el enlace `current` no existe.
- C) Copia los archivos del release en lugar de crear un enlace.

<details>
<summary><b>Mostrar respuesta</b></summary>

**Respuesta: B**
`ln -sfn` elimina el enlace antiguo y crea el nuevo. En ese intervalo, el servidor web puede recibir una petición y no encontrar `current`. Solo `rename(2)` es atómico, por eso el nuevo enlace se crea con un nombre temporal y se renombra mediante `mv -T`.
</details>

### Pregunta 2: Hay que renombrar una columna con la que trabajan los workers de la cola. ¿Qué enfoque es seguro en un despliegue sin tiempo de inactividad?
- A) Una sola migración con `renameColumn` y la actualización simultánea del código.
- B) Añadir una columna nueva, desplegar código que escriba en ambas, migrar los datos y eliminar la columna antigua en un release aparte.
- C) Poner el sitio en modo de mantenimiento solo durante la migración.

<details>
<summary><b>Mostrar respuesta</b></summary>

**Respuesta: B**
El patrón expand/contract garantiza que, en todo momento, tanto el código antiguo como el nuevo encuentran las columnas que necesitan. La opción C ya no es zero-downtime, y la opción A rompe los workers y las peticiones que funcionan con el release antiguo.
</details>

### Pregunta 3: ¿Por qué en una migración con `CREATE INDEX CONCURRENTLY` se indica `public $withinTransaction = false;`?
- A) Para que la migración se ejecute más rápido.
- B) PostgreSQL prohíbe `CREATE INDEX CONCURRENTLY` dentro de un bloque transaccional, y Laravel envuelve por defecto la migración en una transacción.
- C) Para que el índice se cree en todas las réplicas a la vez.

<details>
<summary><b>Mostrar respuesta</b></summary>

**Respuesta: B**
La construcción concurrente de un índice consta de varias fases con transacciones independientes, por lo que no es posible dentro de `BEGIN ... COMMIT`. La propiedad `$withinTransaction = false` desactiva el envoltorio de Laravel para esa migración concreta.
</details>