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

> De WordPress a Astro: el proceso real de poner un sitio estático en producción con Decap CMS, OAuth, Docker y GitHub Actions. Sin filtros, con errores incluidos.

**Autor:** Pedro Luis Cuevas Villarrubia | **Fecha:** 2026-05-09 | **Tags:** Astro, CI/CD, GitHub Actions, Docker, desarrollo web
**URL:** https://asturwebs.es/blog/desplegar-web-estatica-ci-cd-github-actions-2026/

---
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](/images/2026-05-deploy-ci-cd-asturwebs-central-ci-cd-flow.webp)

```text
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)

```text
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:

```javascript
// 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:

```javascript
// 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:

```javascript
// 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:

```yaml
# ~/.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:

```yaml
# .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:

```bash
# /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:

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

**Requisitos críticos de seguridad**:

```bash
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](/contacto/) — llevo 20 años haciendo esto y me gusta compartir lo que aprendo por el camino.