Saltar al contenido
Panel de operaciones con pipeline de deploy automatizado mostrando streams de datos en tonos índigo y cyan sobre fondo oscuro
AstroCI/CDGitHub Actions

Cómo desplegué mi web estática con CI/CD automático (y lo que aprendí por el camino)

P Pedro Luis Cuevas Villarrubia

Migrar a estático es fácil. Lo difícil empieza cuando necesitas que alguien edite contenido sin tocar código, y que ese cambio se publique solo.

Este post documenta cómo resolví el triángulo CMS + OAuth + Deploy automático para un sitio Astro en infraestructura propia. Incluye los tres bugs silenciosos que me volvieron loco, la configuración de seguridad que casi dejo abierta, y el resultado final: editar en el navegador y publicar en 45 segundos.

Por qué Hono y no Astro Server/Actions

Astro tiene Server Islands y Actions nativos. Pero mi API (formulario de contacto, chatbot con IA, OAuth) necesita correr como servicio independiente en Docker, desacoplado del build estático. Si Astro cambia algo en el build, la API sigue funcionando. Si la API cae, la web sigue servida desde Nginx. Separación de responsabilidades real.

La arquitectura final

Pipeline de deploy automatizado: código → build → Docker → servidor

Navegador → Decap CMS → commit a GitHub

                    GitHub Actions (trigger)

                    npm ci → npm run build

                    rsync → VPS + Docker rebuild

                    Web actualizada (~45 segundos)

Stack:

  • Frontend: Astro 5 generando HTML estático
  • API: Hono en Docker (contacto, chatbot IA, OAuth, health check)
  • CMS: Decap CMS con GitHub backend + OAuth propio
  • CI/CD: GitHub Actions con SSH al VPS
  • Infra: VPS con Plesk + Nginx como proxy

Nota de configuración Nginx: El CSP connect-src debe incluir https://api.github.com. Sin esto, el CMS autentica pero se queda colgado en “logging in…” porque no puede llamar a la API de GitHub. No genera error en consola — simplemente no funciona.

El OAuth que no quería funcionar

El flujo OAuth con Decap CMS parece simple. No lo es.

Cómo debería funcionar (5 pasos)

1. CMS abre popup → tu servidor OAuth
2. Tu servidor redirige → github.com/login/oauth/authorize
3. GitHub redirige de vuelta → tu callback con ?code=xxx
4. Tu servidor intercambia code → token de GitHub
5. Popup envía token al CMS via postMessage

El paso 5 es donde todo se rompe. El CMS no acepta cualquier mensaje — usa un protocolo de handshake en dos pasos con formato de cadena específico. Y encima, el navegador puede eliminar window.opener por seguridad.

Tres bugs encadenados. Cada uno silencioso.

Bug 1: Formato de mensaje incorrecto

Mi callback enviaba esto:

// Lo que yo enviaba (objeto)
window.opener.postMessage({ type: 'authorizing', token: token }, '*');

Pero Decap CMS espera cadenas con formato específico, porque internamente usa un regex para parsear el token:

// Lo que el CMS espera (strings)
"authorizing:github"                                          // handshake
"authorization:github:success:{\"token\":\"...\",\"provider\":\"github\"}" // token

El CMS ignora silenciosamente los mensajes que no coinciden con el patrón. Cero errores. Cero pistas.

Bug 2: Handshake en dos pasos

El CMS no acepta el token directamente. El protocolo es:

  1. El popup envía "authorizing:github"
  2. El CMS valida el origin, registra un listener nuevo, y responde con un echo
  3. Solo entonces el popup envía el token real con "authorization:github:success:{...}"

Si envías el token sin el handshake previo, el listener correspondiente no existe y el mensaje se pierde.

Bug 3: window.opener es null

Incluso con el formato y handshake correctos, seguía sin funcionar. Después de investigar la documentación oficial de Decap CMS y el issue #7257, encontré la causa: cuando un popup navega por un dominio diferente (github.com), los navegadores modernos eliminan window.opener por Cross-Origin-Opener-Policy.

Mi solución: El callback escribe el token en localStorage (compartido entre ventanas del mismo dominio). La ventana principal escucha el evento storage y simula el handshake via postMessage hacia sí misma:

// admin/index.html — bridge localStorage → postMessage
window.addEventListener('storage', function(e) {
  if (e.key === 'netlify-cms-user' && e.newValue) {
    var data = JSON.parse(e.newValue);
    if (data.token) {
      var content = JSON.stringify({token: data.token, provider: 'github'});
      window.postMessage('authorizing:github', '*');
      setTimeout(function() {
        window.postMessage('authorization:github:success:' + content, '*');
      }, 100); // Delay necesario: evita race condition con el listener del CMS
    }
  }
});

Caveat: Este localStorage bridge es un workaround por las limitaciones de COOP. La solución “limpia” sería configurar Cross-Origin-Opener-Policy: unsafe-none en el callback (si tu política de seguridad lo permite). El bridge funciona en la mayoría de escenarios, pero puede fallar en modo incógnito restrictivo o con cookies de terceros bloqueadas si el navegador aísla el localStorage entre contextos de navegación.

GitHub Actions: deploy automático

Con el CMS funcionando, la pieza que faltaba: que los cambios se publicaran solos.

El workflow se activa con cada push a main, compila el sitio, rsync al VPS y reconstruye Docker. Desglose de tiempos reales:

FaseTiempoQué hace
npm ci~8sInstala dependencias desde lockfile
npm run build~3sAstro genera 42 páginas HTML estáticas
rsync estáticos~3sDelta sync al VPS (solo archivos cambiados)
rsync API~1sSincroniza código Hono al VPS
Deploy script~20sCopia archivos, rebuild Docker con BuildKit
Total~35-45sDesde push hasta web actualizada

El build de Astro es especialmente rápido porque genera HTML puro — no hay server-side rendering ni hydration en tiempo de build.

SSH multiplexing: el detalle que mató el primer deploy

El primer intento falló con “Connection reset by peer”. Dos conexiones SSH rápidas seguidas y el VPS bloqueaba la segunda por rate-limiting.

La solución: SSH ControlMaster. Una sola conexión que se reutiliza para todos los comandos:

# ~/.ssh/config en GitHub Actions
Host vps
  HostName midominio.com
  User deploy
  ControlMaster auto        # Abre una conexión maestra
  ControlPath ~/.ssh/ctrl_%h_%p_%r  # Socket compartido
  ControlPersist 60         # Mantiene abierta 60s tras el último uso

Ahora rsync estáticos, rsync API y SSH deploy usan la misma conexión TCP. Cero rate-limiting.

La parte clave del workflow:

# .github/workflows/deploy.yml
- name: Setup SSH
  run: |
    mkdir -p ~/.ssh && chmod 700 ~/.ssh
    echo "${{ secrets.VPS_DEPLOY_KEY }}" > ~/.ssh/deploy_key
    chmod 600 ~/.ssh/deploy_key
    ssh-keyscan -H 123.123.123.123 >> ~/.ssh/known_hosts 2>/dev/null
    cat >> ~/.ssh/config << 'EOF'
    Host vps
      HostName 123.123.123.123
      User deploy_user
      IdentityFile ~/.ssh/deploy_key
      ControlMaster auto
      ControlPath ~/.ssh/ctrl_%h_%p_%r
      ControlPersist 60
    EOF

- name: Deploy
  run: |
    rsync -az --delete dist/ vps:~/asturwebs-dist/
    rsync -az src/api/ vps:/opt/myapp-api/src/api/
    ssh vps "sudo /opt/myapp-deploy/deploy.sh"

Tres comandos SSH (rsync + rsync + ssh), una sola conexión TCP. Sin ControlMaster, fail2ban bloquearía la segunda conexión.

Pro-tip: Si tu VPS tiene fail2ban o rate-limiting SSH, SSH multiplexing no es un nice-to-have — es obligatorio para CI/CD.

Seguridad: sudo docker es root total

El primer approach fue dar passwordless sudo a cp, chown, sed y docker. Funcional. Pero sudo docker run --privileged -v /:/host alpine chroot /host te da root completo en el servidor. Si alguien consigue la clave SSH del CI, tiene todo.

La solución correcta: un deploy script específico que solo hace lo necesario:

# /opt/deploy/deploy.sh — propiedad de root, ejecución restringida
#!/bin/bash
set -euo pipefail
cp -r ~/dist/* /var/www/httpdocs/
chown -R webuser:webgroup /var/www/httpdocs/
sed -i '/local_backend: true/d' /var/www/httpdocs/admin/config.yml
cd /opt/api && docker compose build && docker compose up -d

Y en sudoers, solo ese script:

# /etc/sudoers.d/deploy
deploy ALL=(root) NOPASSWD: /opt/deploy/deploy.sh

Requisitos críticos de seguridad:

sudo chown root:root /opt/deploy/deploy.sh   # Propiedad de root
sudo chmod 700 /opt/deploy/deploy.sh           # Solo root puede leer/escribir/ejecutar

Si el usuario deploy puede escribir el script, puede modificarlo para hacer cualquier cosa y ejecutarlo con el sudo autorizado. El script debe ser inalterable para cualquiera que no sea root.

Qué haría diferente

Si empezara hoy, cambiaría dos cosas:

  1. El OAuth lo haría con una Lambda function en vez de un endpoint en mi API. El OAuth se usa una vez cada semanas — mantenerlo en un proceso Docker permanente es desperdicio. Una serverless function en Vercel o Cloudflare Workers habría sido más simple y seguro.

  2. Habría configurado el CI/CD antes del CMS, no después. La primera vez que edité contenido desde Decap CMS y vi que la web no cambiaba, pensé que estaba roto. La desconexión mental entre “guardo en el navegador” y “necesito rebuildar un sitio estático” es real. El CI/CD debería ser la primera pieza, no la última.


¿Estás montando tu propio stack o considerando migrar a estático? Hablemos — llevo 20 años haciendo esto y me gusta compartir lo que aprendo por el camino.

P

Pedro Luis Cuevas Villarrubia

Innovation Practitioner, WebMaster, SysAdmin & SEO · AI Agent Architect & Advanced Prompt Engineer

Hablar con Pedro

Artículos relacionados

¿Y si tu Socio Digital fuera Yo?

Pedro Luis Cuevas Villarrubia · Innovation Practitioner, WebMaster, SysAdmin & SEO · AI Agent Architect & Advanced Prompt Engineer

Hace dos décadas, tener una web era suficiente. Luego llegó el SEO, luego el móvil, luego las redes. Ahora la IA lo cambia todo a velocidad exponencial. No necesitas alguien que te haga la web. Necesitas alguien que conozca tu proyecto, que esté al día del avance tecnológico y que sepa aplicarlo a lo tuyo. Eso es un Socio Digital.

Asesoría Diseño Web SEO Hosting IA Mantenimiento
¿Queda sitio?