Cómo desplegué mi web estática con CI/CD automático (y lo que aprendí por el camino)
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

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:
- El popup envía
"authorizing:github" - El CMS valida el origin, registra un listener nuevo, y responde con un echo
- 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:
| Fase | Tiempo | Qué hace |
|---|---|---|
npm ci | ~8s | Instala dependencias desde lockfile |
npm run build | ~3s | Astro genera 42 páginas HTML estáticas |
| rsync estáticos | ~3s | Delta sync al VPS (solo archivos cambiados) |
| rsync API | ~1s | Sincroniza código Hono al VPS |
| Deploy script | ~20s | Copia archivos, rebuild Docker con BuildKit |
| Total | ~35-45s | Desde 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:
-
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.
-
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.