Cómo desplegué mi web en una Raspberry Pi con Cloudflare Tunnel y CI/CD
Un sitio estático servido desde casa, sin abrir puertos, con despliegue automático desde GitHub. Y los siete fallos encadenados de Tailscale y rsync que me costó.
Quería publicar un sitio estático desde una Raspberry Pi que tengo en casa, con tres condiciones: no abrir ningún puerto en el router, que el despliegue fuera automático al hacer push a GitHub, y no pagar por nada que pudiera hacer yo. Lo conseguí, pero el camino tuvo más baches de los que esperaba. Este es el mapa, para que el próximo (o mi yo futuro) no tropiece con las mismas piedras.
La arquitectura, en una frase
GitHub construye el sitio, entra en mi red privada por Tailscale, copia los
ficheros a la Pi con rsync, y Cloudflare Tunnel publica el nginx de la Pi en
un dominio real. Nada de esto necesita un puerto abierto de cara a internet.
push a main
│
▼
GitHub Actions ── build ── entra al tailnet ── rsync ──▶ Raspberry Pi
│ nginx (solo localhost)
▼
Cloudflare Tunnel ──▶ midominio
Por qué Cloudflare Tunnel y no abrir un puerto
Abrir el 80/443 del router significa exponer mi red doméstica y pelearme con una IP que cambia. Un túnel de Cloudflare le da la vuelta al problema: es la Pi la que sale hacia Cloudflare y mantiene la conexión abierta; el tráfico entra por ahí. El router queda cerrado a cal y canto.
El túnel lo gestiono desde un fichero de configuración versionado, no desde el panel de Cloudflare. Así el “qué se publica y hacia dónde” vive en el repo, y no en un dashboard que dentro de un año no recordaré haber tocado.
Los siete baches (la parte útil)
El diseño es limpio. Ponerlo en marcha fue una cadena de fallos donde cada uno tapaba al siguiente. Por orden de aparición:
- El túnel conectaba a ratos y se caía. Cloudflared usa QUIC (UDP) por
defecto, y muchas redes domésticas lo bloquean. Forzar
protocol: http2lo arregló. - Reintentos infinitos contra el borde. Cloudflared abre 4 conexiones para
tener redundancia; si la red solo deja establecer una, las otras tres mueren
con i/o timeout y llenan el log sin dar redundancia real.
ha-connections: 1. - El contenedor del túnel no leía sus credenciales. Corre como usuario sin
privilegios y las credenciales están en modo
0400de su dueño. Se resuelve arrancándolo con eluid:gidde ese usuario —sin necesidad de root. rsync -e "tailscale ssh"no vale. rsync invoca el shell remoto comossh -l usuario host, y ese envoltorio no acepta la opción-l.ssha secas tampoco. La acción de Tailscale levantatailscaleden modo userspace, así que el kernel del runner no enruta las100.xy la conexión muere en connection timed out. La solución: unProxyCommand tailscale nc %h %pen la configuración de ssh —no enrsync -e, porque rsync parte esa cadena por espacios sin respetar las comillas.- La acción de Tailscale reporta
successmintiendo. Sus intentos detailscale uppueden fallar y el paso sale verde igual. El fallo reaparece mucho más abajo como un error de red en rsync, y persigues el problema equivocado durante media hora. Añadí un paso que compruebatailscale statusy aborta si el runner no ha entrado en el tailnet. - Cloudflare cacheaba mis 404 cuatro horas. Pedí un fichero justo antes de
desplegarlo, el borde se quedó con el 404, y el archivo siguió “sin existir”
para todo el mundo aunque ya estuviera servido. Marcar la página de error como
Cache-Control: no-storelo cura.
La lección que me llevo
De todos, el que más tiempo me robó fue el número 6: una acción que reporta
éxito habiendo fallado. No hay peor bug que el que oculta su propia causa. La
moraleja quedó grabada: si un paso puede fallar en silencio, añade una
comprobación explícita que lo haga gritar. Un success en el que no puedes
confiar es peor que un error honesto.
El resultado: hago push, y un par de minutos después el cambio está en línea, servido desde una placa que cabe en la palma de la mano. Cada vez que lo veo funcionar me sigue pareciendo un poco magia.