← Blog

Reemplaza las review apps de Heroku con vistas previas de pull request

Railyard

15 de julio de 2026 · 7 min de lectura

Sigue esto en tu propio servidor. Empieza gratis, sin tarjeta

Las review apps de Heroku levantan una app desechable completa por cada pull request, lo cual es genuinamente útil para quien revisa: hacer clic en un link en vez de leer un diff e imaginar qué hace. Lo que es fácil de olvidar es que app.json exige addons y una región, cada review app corre con el precio normal de dynos y add-ons de Heroku, y quien paga la factura de una organización paga por la app de cada PR abierta igual que por una de producción, prorrateado al segundo pero no gratis. Esta guía construye el mismo flujo de trabajo tú mismo con GitHub Actions y Docker en un solo servidor, y luego muestra cómo se ve una versión hospedada de esto una vez que toma en cuenta el problema de costo real: las apps de vista previa inactivas.

Al final tendrás un sistema de vistas previas por PR funcionando que construiste y entiendes completamente, y sabrás exactamente qué compromiso hace en realidad una función de vista previa hospedada en tu nombre.

Lo que necesitas

  • Un servidor donde tu app principal (o una copia de staging) ya se despliega como imagen Docker. Un Hetzner CX23 (2 vCPU, 4 GB de RAM, €5.99/mes sin IVA) maneja un puñado de entornos de vista previa simultáneos para una app web típica.
  • Un repositorio de GitHub con Actions habilitado, y una forma de construir y subir una imagen Docker etiquetada por pull request (la mayoría de las configuraciones de CI ya hacen esto para la rama principal; esta guía etiqueta por número de PR en su lugar).
  • Un registro DNS comodín o un subdominio por vista previa (esta guía usa pr-<número>.review.example.com), y Caddy ya corriendo en el servidor para el HTTPS.
  • Acceso SSH desde GitHub Actions hacia el servidor (una llave de despliegue basta; esta guía no cubre configurar eso, ya que la mayoría de los equipos ya tiene una para su despliegue principal).

Paso 1: construye y etiqueta una imagen por pull request

Lo que ya construye tu imagen de producción funciona aquí; el único cambio es la etiqueta:

name: Build PR image
on:
  pull_request:
    types: [opened, synchronize, reopened]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build and push
        run: |
          docker build -t ghcr.io/you/app:pr-${{ github.event.pull_request.number }} .
          echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
          docker push ghcr.io/you/app:pr-${{ github.event.pull_request.number }}

Etiquetar por número de PR en vez de por SHA del commit significa que cada push a la misma PR sobrescribe la misma etiqueta, que es exactamente el comportamiento de una review app: la vista previa sigue a la rama, no a un commit congelado.

Paso 2: escribe la plantilla Compose por PR

services:
  app:
    image: ghcr.io/you/app:pr-${PR_NUMBER}
    restart: unless-stopped
    env_file:
      - .env
    ports:
      - "127.0.0.1:${APP_PORT}:3000"

Enlazar a 127.0.0.1 mantiene el contenedor inalcanzable directamente desde internet; Caddy es el único punto de entrada público, igual que un solo servidor de producción con varias apps normalmente las aísla entre sí en un mismo host.

Paso 3: escribe el script de despliegue

Esto corre en el servidor mismo, disparado por SSH desde el workflow de Actions:

#!/usr/bin/env bash
set -euo pipefail

PR_NUMBER="$1"
APP_PORT=$((40000 + PR_NUMBER))
DIR="/opt/previews/pr-$PR_NUMBER"

mkdir -p "$DIR"
cp /opt/previews/template/docker-compose.yml "$DIR/"
cp /opt/previews/template/.env "$DIR/.env"

export PR_NUMBER APP_PORT
docker compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" pull
docker compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" up -d

cat > "/etc/caddy/sites/pr-$PR_NUMBER.caddyfile" <<EOF
pr-$PR_NUMBER.review.example.com {
    reverse_proxy 127.0.0.1:$APP_PORT
}
EOF

docker exec caddy caddy reload --config /etc/caddy/Caddyfile

Derivar APP_PORT del número de PR (40000 + PR_NUMBER) es una forma simple de evitar colisiones de puerto entre PRs abiertas simultáneamente sin un registro separado para rastrear qué está tomado; funciona hasta tener más de unos pocos miles de PRs abiertas a la vez, lo cual no es una restricción real para casi ningún equipo.

El Caddyfile principal necesita una línea agregada una vez, de entrada, para que recoja automáticamente cada archivo de sitio generado:

import /etc/caddy/sites/*.caddyfile

Paso 4: conéctalo al workflow de GitHub Actions

  deploy-preview:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Deploy preview
        run: |
          ssh deploy@your-server "/opt/previews/deploy.sh ${{ github.event.pull_request.number }}"

Agrega un job correspondiente pull_request: types: [closed] que corra un script de desmontaje en su lugar: docker compose down, elimina el directorio pr-<número>, borra el Caddyfile generado, y recarga Caddy. Una PR cerrada con un contenedor dando vueltas es el equivalente de review app de un servidor que nadie se acordó de apagar.

Paso 5: confirma de extremo a extremo

Abre una PR de prueba, espera a que el workflow termine, y visita https://pr-<número>.review.example.com; deberías ver corriendo la rama de esa PR, no la app de producción. Sube un commit nuevo a la misma PR y confirma que la imagen se reconstruye y que la misma URL ahora sirve el commit nuevo. Cierra la PR y confirma que el contenedor y el archivo de sitio de Caddy desaparecen en menos de un minuto.

El atajo: hazlo con Railyard

Todo lo anterior, el build por PR, el puerto dinámico, la config de Caddy generada, el desmontaje al cerrar, es lo que la función de entornos de vista previa de Railyard hace automáticamente una vez que GitHub está conectado y las vistas previas están activadas para una app. Cada pull request obtiene su propia copia completa automáticamente, mismo build, mismos tipos de proceso, su propia URL, eliminada en el momento en que la PR se cierra, sin archivo de workflow ni script de despliegue que mantener tú mismo.

Lo que vale la pena señalar específicamente, ya que es el problema real de las review apps de Heroku más que un detalle menor: las apps nuevas de vista previa y staging se suspenden por defecto después de 30 minutos sin tráfico, despertando en la siguiente solicitud. Una PR abierta dos semanas mientras alguien está de vacaciones no es un contenedor dando vueltas a costo completo todo ese tiempo; está dormido casi todo el tiempo. Eso es configurable por app en Settings → General (nunca, 10 minutos, 30 minutos, 1 hora, 6 horas, o un día sin tráfico, o horas de actividad específicas en su lugar), y es lo único que la configuración manual de los pasos 1 a 5 no te da sin construirlo tú mismo, ya que nada en ese script evita que un contenedor corra a costo completo mientras la PR siga abierta. Hay un límite de 5 vistas previas simultáneas por app, que rara vez importa para la cantidad de PRs abiertas de un equipo típico; referencia completa: Entornos y vistas previas.

Solución de problemas

La URL de vista previa da 404 aunque el script de despliegue corrió. Revisa que docker exec caddy caddy reload --config /etc/caddy/Caddyfile de verdad haya tenido éxito; un error de sintaxis en un archivo de sitio generado hace fallar la recarga silenciosamente a menos que revises su código de salida, dejando a Caddy corriendo con la configuración anterior.

Dos PRs sirven el mismo contenido. APP_PORT colisionó, usualmente porque la fórmula de derivación del puerto se cambió sin revisar primero las vistas previas ya corriendo; docker ps --format '{{.Names}} {{.Ports}}' muestra qué está realmente enlazado dónde.

Los contenedores se acumulan después de que las PRs se cierran. El workflow de desmontaje no está configurado para el tipo de PR closed, o GitHub no lo disparó porque la PR se cerró sin fusionarse por una vía que el filtro types de tu workflow no cubre; confirma que types: [closed] capture tanto PRs fusionadas como simplemente cerradas, ya que GitHub dispara el mismo evento para ambas.

La vista previa muestra código desactualizado después de un push. Confirma que la etiqueta de imagen en la plantilla Compose de verdad coincide con lo que el paso 1 acaba de subir; docker compose pull antes de up -d es necesario, ya que Compose no descarga una imagen nueva para una etiqueta existente a menos que se le indique.

Preguntas frecuentes

¿Esto funciona con GitLab en vez de GitHub? El mismo patrón funciona con GitLab CI/CD en vez de Actions; la plantilla Compose por PR, la generación de config de Caddy y el script de desmontaje no cambian, solo cambia la sintaxis de workflow que los dispara.

¿Las vistas previas pueden compartir una base de datos con producción? Pueden, pero una migración corriendo contra la conexión de una vista previa a la base de datos de producción es un riesgo real que vale la pena evitar; una base de datos por vista previa, incluso una desechable barata sembrada desde un snapshot, es más segura y es lo que hace por defecto la función de entornos de Railyard, exactamente por esta razón.

¿Qué pasa con los secretos que necesita cada vista previa? La plantilla .env de la configuración manual es una brecha real: necesita valores reales para todo lo que la app requiera (llaves de API, URL de base de datos), y copiar secretos de producción a cada vista previa es su propia cuestión de seguridad que vale la pena pensar por separado, usualmente delimitando credenciales exclusivas para vistas previas desde el inicio.

¿De verdad alcanza un CX23 para varias vistas previas a la vez? Para un puñado de vistas previas livianas corriendo la misma app como un solo proceso cada una, sí; este es exactamente el tipo de carga de trabajo que más ayuda una política de suspensión por inactividad, ya que la mayoría de las PRs abiertas no se están revisando activamente en un momento dado.

Las review apps de Heroku acertaron en algo que vale la pena conservar: quien revisa debería recibir un link que corre, no un diff que imaginar. Lo que hacen mal es facturar cada PR abierta como un servidor de producción sin importar si alguien de verdad la está mirando, lo cual los pasos 1 a 5 de arriba reproducen exactamente a menos que también construyas la lógica de suspensión tú mismo. Eso es lo único que vale la pena tener claro antes de copiar cualquier función de entorno de vista previa, hospedada o construida por ti mismo.

Por qué eligen Railyard para esto

  • Tu propia cuenta en la nube. Despliega en DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, o cualquier servidor Linux por SSH, facturado directamente por tu proveedor, sin márgenes adicionales.
  • Un precio fijo por equipo. El plan Free es gratis para siempre: un servidor más un servidor de base de datos, tres apps, sin tarjeta. Pro cuesta $19 al mes por hasta cinco servidores y apps ilimitadas. Team cuesta $49 al mes con servidores ilimitados. Sin cargos por app, por despliegue, por asiento ni por gigabyte.
  • Plantillas de un clic. Despliega apps de código abierto, como bases de datos, analítica y herramientas de desarrollo, en tu propio servidor con un clic.
  • Bases de datos administradas. Postgres, MySQL y Redis, con copias de seguridad programadas y verificadas por restauración, y recuperación a un punto en el tiempo.
  • Despliegue con git push. Despliega desde GitHub, GitLab o la CLI, con HTTPS automático y lanzamientos sin tiempo de inactividad.

Activa las vistas previas para una app

Conecta GitHub, activa un ajuste, y cada PR después de eso obtiene su propia copia que se suspende cuando nadie la mira.

Plan gratis, sin tarjeta, y 14 días de Pro para cada equipo nuevo. Un precio fijo por equipo.