Módulo 7: Alternative Platforms (Render, Railway, Fly.io)

6. CI/CD Integration con Plataformas Alternativas

Descripción

En esta cápsula vas a integrar GitHub Actions con Render, Railway y Fly.io para automatizar deployments. Las tres plataformas ofrecen auto-deploy desde Git, pero en producción necesitas más control: tests antes del deploy, environment promotion (staging → production), rollback automatizado, y notificaciones. GitHub Actions (que ya conoces del prerequisite #16) es la pieza que conecta tu pipeline de CI con el deployment en la plataforma elegida.

Contexto: Las cápsulas anteriores (02-04) desplegaron manualmente — dashboard o CLI. Eso está bien para la primera vez, pero en un equipo y en producción necesitas que el proceso sea reproducible, auditable y automático. Un push a main debe ejecutar tests, construir la imagen, desplegar a staging, verificar health, y promover a producción. Esta cápsula construye ese pipeline para cada plataforma.


El Pipeline de Deployment

Estructura del pipeline

Push a main
    ↓
GitHub Actions
    ├── 1. Checkout código
    ├── 2. Setup Python
    ├── 3. Instalar dependencias
    ├── 4. Ejecutar tests
    ├── 5. Build Docker image (opcional, depende de plataforma)
    ├── 6. Deploy a staging
    ├── 7. Health check en staging
    ├── 8. Deploy a production (si staging pasa)
    └── 9. Notificación (Slack, Discord, etc.)

Prerequisitos

# Necesitas:
# 1. Repositorio en GitHub
# 2. App desplegada en al menos una plataforma (M7 caps 02-04)
# 3. Tests básicos para tu app
# 4. GitHub Actions habilitado en tu repo

# Verificar estructura
ls .github/workflows/
# Si no existe, créalo:
mkdir -p .github/workflows

Tests básicos para tu app AI

Antes de automatizar el deploy, necesitas tests que verifiquen que tu app funciona:

# tests/test_app.py
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch, MagicMock

from main import app

client = TestClient(app)


def test_health_check():
    response = client.get("/health")
    assert response.status_code == 200
    data = response.json()
    assert data["status"] == "healthy"
    assert "version" in data


def test_ask_requires_api_key():
    with patch.dict("os.environ", {}, clear=True):
        response = client.post(
            "/ask",
            json={"question": "test", "max_tokens": 50},
        )
        assert response.status_code == 500


@patch("main.openai.OpenAI")
def test_ask_returns_answer(mock_openai):
    mock_response = MagicMock()
    mock_response.choices = [MagicMock()]
    mock_response.choices[0].message.content = "Test answer"
    mock_response.usage.total_tokens = 42
    mock_openai.return_value.chat.completions.create.return_value = mock_response

    with patch.dict("os.environ", {"OPENAI_API_KEY": "sk-test"}):
        response = client.post(
            "/ask",
            json={"question": "¿Qué es Python?", "max_tokens": 50},
        )
        assert response.status_code == 200
        data = response.json()
        assert "answer" in data
# tests/requirements-test.txt
pytest==8.3.0
httpx==0.27.0

GitHub Actions + Render

Opción 1: Auto-deploy nativo (sin GitHub Actions)

Render despliega automáticamente cuando pusheas a main. No necesitas GitHub Actions para deploy básico. Pero si quieres tests antes del deploy:

Opción 2: Deploy controlado con GitHub Actions

# .github/workflows/deploy-render.yml
name: Deploy to Render

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  RENDER_SERVICE_ID: ${{ secrets.RENDER_SERVICE_ID }}

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: |
          pip install -r app/requirements.txt
          pip install -r tests/requirements-test.txt

      - name: Run tests
        run: pytest tests/ -v

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - name: Trigger Render Deploy
        run: |
          curl -X POST \
            "https://api.render.com/v1/services/${{ secrets.RENDER_SERVICE_ID }}/deploys" \
            -H "Authorization: Bearer ${{ secrets.RENDER_API_KEY }}" \
            -H "Content-Type: application/json" \
            -d '{"clearCache": "do_not_clear"}'

      - name: Wait for deploy
        run: sleep 120

      - name: Health check
        run: |
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
            ${{ secrets.RENDER_URL }}/health)
          if [ "$STATUS" != "200" ]; then
            echo "Health check failed with status $STATUS"
            exit 1
          fi
          echo "Health check passed"

Configurar secrets en GitHub

GitHub → tu repo → Settings → Secrets and variables → Actions

Agregar:
- RENDER_API_KEY: (Dashboard → Account Settings → API Keys)
- RENDER_SERVICE_ID: (Dashboard → tu servicio → la URL contiene el ID: srv-xxx)
- RENDER_URL: https://tu-servicio.onrender.com

Deshabilitar auto-deploy de Render

Si usas GitHub Actions para controlar el deploy, desactiva el auto-deploy de Render:

Dashboard → tu servicio → Settings → Build & Deploy
Auto-Deploy: OFF

Ahora solo GitHub Actions trigger deploys, después de pasar tests.


GitHub Actions + Railway

Deploy con Railway CLI en GitHub Actions

# .github/workflows/deploy-railway.yml
name: Deploy to Railway

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: |
          pip install -r app/requirements.txt
          pip install -r tests/requirements-test.txt

      - name: Run tests
        run: pytest tests/ -v

  deploy-staging:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - uses: actions/checkout@v4

      - name: Install Railway CLI
        run: npm install -g @railway/cli

      - name: Deploy to staging
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
        run: railway up --environment staging --detach

      - name: Wait for staging deploy
        run: sleep 60

      - name: Health check staging
        run: |
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
            ${{ secrets.RAILWAY_STAGING_URL }}/health)
          if [ "$STATUS" != "200" ]; then
            echo "Staging health check failed"
            exit 1
          fi

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Install Railway CLI
        run: npm install -g @railway/cli

      - name: Deploy to production
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
        run: railway up --environment production --detach

      - name: Wait for production deploy
        run: sleep 60

      - name: Health check production
        run: |
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
            ${{ secrets.RAILWAY_PRODUCTION_URL }}/health)
          if [ "$STATUS" != "200" ]; then
            echo "Production health check failed!"
            exit 1
          fi
          echo "Production deploy successful"

Obtener Railway Token

# Generar token de servicio (no usa tu sesión personal)
# Dashboard → tu proyecto → Settings → Tokens → Create Token

# O desde CLI:
railway tokens create
# Token: rlwy_xxx

# Agregar a GitHub Secrets:
# RAILWAY_TOKEN = rlwy_xxx
# RAILWAY_STAGING_URL = https://...staging.up.railway.app
# RAILWAY_PRODUCTION_URL = https://...production.up.railway.app

Configurar entornos en Railway

# Railway soporta múltiples entornos nativamente
# Dashboard → Project → Settings → Environments

# Crear entorno staging:
# + New Environment → staging

# Cada entorno tiene:
# - Sus propias variables
# - Su propia URL
# - Su propia instancia de base de datos (opcional)

GitHub Actions + Fly.io

Deploy con flyctl en GitHub Actions

# .github/workflows/deploy-flyio.yml
name: Deploy to Fly.io

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: |
          pip install -r app/requirements.txt
          pip install -r tests/requirements-test.txt

      - name: Run tests
        run: pytest tests/ -v

  deploy-staging:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - uses: actions/checkout@v4

      - name: Setup Fly.io CLI
        uses: superfly/flyctl-actions/setup-flyctl@master

      - name: Deploy to staging
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
        run: flyctl deploy --app docusearch-ai-staging --remote-only

      - name: Health check staging
        run: |
          sleep 30
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
            https://docusearch-ai-staging.fly.dev/health)
          if [ "$STATUS" != "200" ]; then
            echo "Staging health check failed"
            exit 1
          fi

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Fly.io CLI
        uses: superfly/flyctl-actions/setup-flyctl@master

      - name: Deploy to production
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
        run: flyctl deploy --app docusearch-ai --remote-only

      - name: Health check production
        run: |
          sleep 30
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
            https://docusearch-ai.fly.dev/health)
          if [ "$STATUS" != "200" ]; then
            echo "Production health check failed!"
            flyctl releases --app docusearch-ai
            exit 1
          fi
          echo "Production deploy successful"

      - name: Show deployment info
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
        run: |
          flyctl status --app docusearch-ai
          flyctl releases --app docusearch-ai --image

Obtener Fly.io API Token

# Crear token
flyctl tokens create deploy -x 999999h
# Token: FlyV1 xxx

# Agregar a GitHub Secrets:
# FLY_API_TOKEN = FlyV1 xxx

Fly.io: Apps de staging y producción

# Crear app de staging (separada de producción)
flyctl launch --name docusearch-ai-staging --region iad --no-deploy

# Copiar secrets a staging
flyctl secrets set OPENAI_API_KEY=sk-staging-xxx --app docusearch-ai-staging

# Ahora tienes:
# Production: docusearch-ai.fly.dev
# Staging: docusearch-ai-staging.fly.dev

Environment Promotion: Staging → Production

El patrón de promotion

Feature branch → PR → Tests → Merge to main
    ↓
Deploy a STAGING (automático en push a main)
    ↓
Health check + smoke tests en staging
    ↓
Deploy a PRODUCTION (automático si staging pasa)
    ↓
Health check en production
    ↓
Notificación de éxito/fallo

Workflow de promotion

El patrón es: test → staging → smoke tests → production → health check. Cada job depende del anterior. Si staging falla, production no se ejecuta. El workflow completo se implementa en los ejercicios de esta cápsula combinando los bloques de cada plataforma mostrados arriba.


Comparativa: CI/CD por Plataforma

AspectoRenderRailwayFly.io
Auto-deploy nativo✅ Git push✅ Git push❌ (necesita Actions)
GitHub Actions setupAPI REST (curl)CLI (railway up)CLI (flyctl deploy)
Action oficial✅ superfly/flyctl-actions
Multi-environmentManual (servicios separados)✅ Nativo (environments)Manual (apps separadas)
RollbackDashboard (redeploy anterior)Dashboard + CLIflyctl releases rollback
Deploy hooks✅ Deploy hooks URL✅ Webhooks❌ (usar Actions)
Secret managementDashboard/APICLI + DashboardCLI + Dashboard

Troubleshooting

Problema 1: "GitHub Actions falla — railway/flyctl command not found"

Solución: Necesitas instalar la CLI en el runner de GitHub Actions:

# Railway
- name: Install Railway
  run: npm install -g @railway/cli

# Fly.io (usar action oficial)
- uses: superfly/flyctl-actions/setup-flyctl@master

Problema 2: "Deploy funciona local pero falla en CI — authentication error"

Solución: En CI no puedes usar login interactivo. Necesitas tokens:

# Railway — usa RAILWAY_TOKEN
env:
  RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}

# Fly.io — usa FLY_API_TOKEN
env:
  FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

# Render — usa API key en headers
-H "Authorization: Bearer ${{ secrets.RENDER_API_KEY }}"

Problema 3: "Health check pasa en staging pero falla en production"

Solución: Variables de entorno diferentes entre staging y production. Verifica:

- name: Debug environment
  run: |
    echo "Checking staging..."
    curl -s ${{ secrets.STAGING_URL }}/health | jq .
    echo "Checking production..."
    curl -s ${{ secrets.PRODUCTION_URL }}/health | jq .

Causas comunes:

  • API key de producción expirada o incorrecta
  • Base de datos de producción no migrada
  • Límites de plan diferentes (Free vs Starter)

Problema 4: "El pipeline tarda mucho — 10+ minutos"

Solución: Optimiza el pipeline:

# Cachear dependencias Python
- uses: actions/setup-python@v5
  with:
    python-version: "3.11"
    cache: "pip"

# Reducir wait times (usar polling en vez de sleep fijo)
- name: Health check with polling
  run: |
    for i in $(seq 1 20); do
      STATUS=$(curl -s -o /dev/null -w "%{http_code}" $URL/health 2>/dev/null)
      if [ "$STATUS" = "200" ]; then exit 0; fi
      sleep 10
    done
    exit 1

Problema 5: "Quiero rollback automático si production falla"

Solución: Cada plataforma tiene su mecanismo:

# Fly.io — rollback al release anterior
flyctl releases rollback --app docusearch-ai

# Railway — redeploy desde un commit anterior
railway up --commit abc123

# Render — desde dashboard, click "Manual Deploy" en un deploy anterior
# O via API:
curl -X POST "https://api.render.com/v1/services/$SVC_ID/deploys" \
  -H "Authorization: Bearer $API_KEY" \
  -d '{"commitId": "abc123"}'

Ejercicios Prácticos

Ejercicio 1: Pipeline básico de CI/CD

Crea un GitHub Actions workflow que ejecute tests y despliegue a tu plataforma elegida cuando haces push a main.

Ver solución
# .github/workflows/deploy.yml
name: Test and Deploy

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"

      - name: Install dependencies
        working-directory: app
        run: |
          pip install -r requirements.txt
          pip install pytest httpx

      - name: Run tests
        working-directory: app
        run: pytest tests/ -v

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - uses: actions/checkout@v4

      # === Elegir UNO según tu plataforma ===

      # Render:
      - name: Deploy to Render
        if: false  # Cambiar a true si usas Render
        run: |
          curl -X POST \
            "https://api.render.com/v1/services/${{ secrets.RENDER_SERVICE_ID }}/deploys" \
            -H "Authorization: Bearer ${{ secrets.RENDER_API_KEY }}" \
            -H "Content-Type: application/json"

      # Railway:
      - name: Deploy to Railway
        if: false  # Cambiar a true si usas Railway
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
        run: |
          npm install -g @railway/cli
          railway up --detach

      # Fly.io:
      - name: Deploy to Fly.io
        if: false  # Cambiar a true si usas Fly.io
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
        run: |
          curl -L https://fly.io/install.sh | sh
          flyctl deploy --remote-only

      - name: Health check
        run: |
          sleep 60
          curl -f ${{ secrets.APP_URL }}/health
# Configurar secrets en GitHub
# Settings → Secrets → Actions → New repository secret

# Para Render:
# RENDER_API_KEY, RENDER_SERVICE_ID, APP_URL

# Para Railway:
# RAILWAY_TOKEN, APP_URL

# Para Fly.io:
# FLY_API_TOKEN, APP_URL

# Push y verifica
git add .github/workflows/deploy.yml
git commit -m "Add CI/CD pipeline"
git push origin main

# Ve a GitHub → Actions para monitorear el pipeline

Ejercicio 2: Pipeline con staging → production

Extiende el pipeline anterior para que despliegue primero a staging, ejecute smoke tests, y luego promueva a production. Usa GitHub Environments para separar staging/production con sus propias variables.

Ver solución
# .github/workflows/deploy-staged.yml
name: Staged Deployment

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"
      - run: pip install -r app/requirements.txt pytest httpx
      - run: pytest tests/ -v

  staging:
    needs: test
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to staging
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
        run: |
          npm install -g @railway/cli
          railway up --environment staging --detach
      - name: Verify staging
        run: |
          sleep 60
          curl -f ${{ vars.STAGING_URL }}/health

  production:
    needs: staging
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
        run: |
          npm install -g @railway/cli
          railway up --environment production --detach
      - name: Verify production
        run: |
          sleep 60
          curl -f ${{ vars.PRODUCTION_URL }}/health

Ejercicio 3: Configurar rollback automático

Agrega un step que haga rollback automático si el health check de producción falla después del deploy.

Ver solución
# Agregar después del deploy de producción:
  production:
    needs: smoke-test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Fly CLI
        uses: superfly/flyctl-actions/setup-flyctl@master

      - name: Get current release
        id: pre_deploy
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
        run: |
          CURRENT=$(flyctl releases --app docusearch-ai --json | \
            python3 -c "import sys,json; print(json.load(sys.stdin)[0]['Version'])")
          echo "version=$CURRENT" >> $GITHUB_OUTPUT
          echo "Current release: v$CURRENT"

      - name: Deploy
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
        run: flyctl deploy --app docusearch-ai --remote-only

      - name: Health check with rollback
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
        run: |
          sleep 30

          HEALTHY=false
          for i in $(seq 1 10); do
            STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
              https://docusearch-ai.fly.dev/health 2>/dev/null)
            if [ "$STATUS" = "200" ]; then
              HEALTHY=true
              break
            fi
            echo "Health check attempt $i failed (status: $STATUS)"
            sleep 10
          done

          if [ "$HEALTHY" = "false" ]; then
            echo "::error::Production unhealthy — rolling back to v${{ steps.pre_deploy.outputs.version }}"
            flyctl releases rollback --app docusearch-ai \
              ${{ steps.pre_deploy.outputs.version }}
            sleep 30

            ROLLBACK_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
              https://docusearch-ai.fly.dev/health 2>/dev/null)
            echo "Rollback health check: $ROLLBACK_STATUS"
            exit 1
          fi

          echo "Production deploy successful"

Ejercicio 4: Notificación de deploy por webhook

Agrega notificaciones de deploy exitoso/fallido usando un webhook (Discord, Slack, o cualquier URL).

Ver solución
# Job de notificación al final del pipeline
  notify:
    needs: [test, staging, production]
    runs-on: ubuntu-latest
    if: always()

    steps:
      - name: Build notification payload
        id: payload
        run: |
          if [ "${{ needs.production.result }}" = "success" ]; then
            STATUS="success"
            COLOR="3066993"
            MSG="Deploy exitoso en producción"
          elif [ "${{ needs.staging.result }}" = "failure" ]; then
            STATUS="failed"
            COLOR="15158332"
            MSG="Deploy falló en staging"
          elif [ "${{ needs.test.result }}" = "failure" ]; then
            STATUS="failed"
            COLOR="15158332"
            MSG="Tests fallaron — deploy abortado"
          else
            STATUS="failed"
            COLOR="15158332"
            MSG="Deploy falló en producción"
          fi
          echo "status=$STATUS" >> $GITHUB_OUTPUT
          echo "color=$COLOR" >> $GITHUB_OUTPUT
          echo "msg=$MSG" >> $GITHUB_OUTPUT

      - name: Send Discord notification
        if: secrets.DISCORD_WEBHOOK != ''
        run: |
          curl -X POST "${{ secrets.DISCORD_WEBHOOK }}" \
            -H "Content-Type: application/json" \
            -d '{
              "embeds": [{
                "title": "Deploy: ${{ steps.payload.outputs.status }}",
                "description": "${{ steps.payload.outputs.msg }}",
                "color": ${{ steps.payload.outputs.color }},
                "fields": [
                  {"name": "Repo", "value": "${{ github.repository }}", "inline": true},
                  {"name": "Branch", "value": "${{ github.ref_name }}", "inline": true},
                  {"name": "Commit", "value": "${{ github.sha }}", "inline": false}
                ]
              }]
            }'

      - name: Send Slack notification
        if: secrets.SLACK_WEBHOOK != ''
        run: |
          curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
            -H "Content-Type: application/json" \
            -d '{
              "text": "${{ steps.payload.outputs.msg }} — ${{ github.repository }}@${{ github.ref_name }}"
            }'

Resumen

  • GitHub Actions conecta tu pipeline de CI con el deployment en cualquier plataforma.
  • Render no tiene CLI — el deploy se trigger via API REST con curl.
  • Railway se integra con su CLI (railway up --environment xxx) usando RAILWAY_TOKEN.
  • Fly.io tiene action oficial (superfly/flyctl-actions) y CLI potente.
  • Environment promotion (staging → production) es el patrón profesional: nunca despliegues a producción sin verificar en staging.
  • Health checks después del deploy son obligatorios — no asumas que el deploy fue exitoso porque no hubo error.
  • Rollback automático es posible en Fly.io (releases rollback), Railway (redeploy commit), y Render (API deploy con commitId).
  • Tests antes del deploy son la primera línea de defensa — si los tests fallan, el deploy no se ejecuta.

Recursos Adicionales

  1. GitHub Actions Documentation — Documentación oficial
  2. Render Deploy Hooks — Trigger deploys desde CI
  3. Railway CI/CD — Guía de integración CI/CD
  4. Fly.io GitHub Actions — Guía oficial de CI/CD
  5. GitHub Environments — Staging/production environments
  6. Deployment Best Practices — Martin Fowler — Principios de deployment pipeline