··
Migré los paneles admin del VPS (Umami, Infisical y Dokploy) a Cloudflare Tunnel con Access. Los puertos 80 y 443 siguen siendo solo para apps públicas, los paneles ya no resuelven al origen ni necesitan login propio expuesto a Internet. Compose mínimo, política de Bypass para webhooks críticos y catch-all con OTP por email.

Con la zona ya bien blindada por la edge (WAF, rate limit, AOP, firewall por IPs de CF) seguía habiendo una pieza que no me gustaba, los paneles administrativos. El admin del blog vive detrás de la sesión de Better Auth y eso me deja tranquilo, pero el resto, el panel de Dokploy, el de Infisical, el dashboard de Umami para analítica, todos eran subdominios públicos en Cloudflare con Traefik enrutando a un servicio que servía un login. La autenticación está bien hecha en cada uno de ellos, pero el principio de mínima exposición decía otra cosa, un panel admin no debería ni siquiera responder a una petición HTTP de un visitante anónimo.
La opción evidente era Cloudflare Tunnel con Cloudflare Access delante. Hace que el VPS abra una conexión saliente al edge de Cloudflare y que el panel ni siquiera tenga DNS apuntando al origen. Antes de servir un solo byte, Access exige una identidad. Si no estás autenticado, ni llegas al login del panel.
Lo monté el 27 de abril de 2026 con tres pilotos en cascada, primero Umami, después Infisical, y al final Dokploy. Esta entrega cuenta el patrón y las trampas, que son menos obvias de lo que parece sobre todo cuando el panel también recibe webhooks externos.
Las alternativas que descarté.
El panorama completo de túneles, mesh y reverse proxy (cuándo conviene cada uno y cuándo no son sustitutos sino piezas distintas) lo dejo en SSH vs Cloudflare Tunnel vs Pangolin vs Tailscale vs Headscale vs WireGuard, qué uso para qué.
Cloudflare Tunnel con Access cumple cuatro cosas a la vez. El origen no acepta ninguna conexión entrante para esos hosts (literalmente no hay registro A público, el DNS resuelve a un CNAME interno de CF). La identidad se valida con un IdP serio (en Free, OTP por email es suficiente para mí). Las sesiones son centralizadas y revocables. Y todo se gestiona desde el mismo dashboard que ya uso para el resto de la zona.
cloudflaredEl truco bonito de Tunnel es que necesitas exactamente un contenedor cloudflared en el VPS, da igual cuántos paneles enrutes después. Cada panel se añade desde el dashboard como Public Hostname dentro del mismo tunnel. Para que cualquier servicio del orquestador pueda ser destino, el contenedor de cloudflared tiene que estar en la red interna de Docker (en mi caso dokploy-network) y referenciar al servicio por nombre.
El compose vive como una entrada propia dentro del proyecto de infraestructura del orquestador, slug infrastructure-cf-tunnel, y es minúsculo.
services:
cloudflared:
image: cloudflare/cloudflared:2026.3.0
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${TUNNEL_TOKEN}
networks:
- dokploy-network
networks:
dokploy-network:
external: trueEl TUNNEL_TOKEN se genera en Zero Trust > Networks > Tunnels al crear el tunnel y se inyecta como variable de entorno desde la UI del orquestador. La imagen está fijada por versión (no latest) y --no-autoupdate evita que el binario se actualice por su cuenta sin que yo lo controle.
El nombre del tunnel en CF lo dejé como dokploy-vps-admin, lo cual deja claro su propósito y separa este tunnel de cualquier otro que pueda crear en el futuro para un caso distinto.
Una vez existe el tunnel, dar de alta un panel nuevo es siempre la misma rutina.
analytics, secretos, dokploy...), dominio (tu-dominio.com), tipo HTTP y como URL el nombre del servicio en la red interna con su puerto interno. Para Umami fue umami:3000, para Infisical infisical-backend:8080, para Dokploy dokploy:3000. CF crea automáticamente un CNAME en la zona apuntando al tunnel.Una Application en Access es básicamente la pareja (host + path) → política. Una misma URL puede tener varias Applications, una por path, evaluadas de más específica a menos. La regla operativa que me funcionó es siempre la misma, crear primero los Bypass de los paths específicos y al final el catch-all que protege el resto. Si haces el catch-all primero y los bypass después, durante esos minutos los webhooks externos se rompen y el deploy automático del orquestador se queda parado.
Cada Bypass es una Self-Hosted Application con la política Bypass + Everyone. El catch-all es otra Self-Hosted Application con path vacío y política Allow + Emails = [email protected].
Umami sirve un dashboard privado pero el script público /script.js y el endpoint de tracking /api/send tienen que ser accesibles por todos los visitantes de mis sitios, sin OTP, evidentemente. Dos Bypass apps y un catch-all.
analytics.tu-dominio.com/script.js, política Bypass + Everyone.analytics.tu-dominio.com/api/send, política Bypass + Everyone.analytics.tu-dominio.com/*, Allow + Email [email protected].Infisical es el caso más limpio. Las apps que consumen secretos (CV, Blog, ScamDetector...) no llaman al subdominio público, llaman a la red interna de Docker, http://infisical-backend:8080. Esa ruta nunca sale a Internet ni pasa por el tunnel, así que para Infisical solo hace falta el catch-all.
secretos.tu-dominio.com/*, Allow + Email.Si tu setup llama a Infisical desde fuera del VPS, esto cambia (necesitarías un Service Token específico expuesto por una API distinta), pero todas mis integraciones son intra-host.
El más delicado. El panel de Dokploy es lo que menos quiero exponer, pero también es lo que recibe más webhooks externos legítimos. Tres Bypass apps y un catch-all.
/api/deploy. Cubre tres rutas distintas que cuelgan de ahí, /api/deploy/github (webhook de la GitHub App, dispara el deploy de los proyectos con auto-deploy), /api/deploy/[refreshToken] (webhooks custom por aplicación) y /api/deploy/compose/[refreshToken] (webhooks custom por compose). Tengo 8 proyectos con autoDeploy: true, romper este path supone romper todos los deploys automáticos./api/providers. OAuth callbacks de GitHub, Gitea y GitLab más el webhook /api/providers/github/webhook. Si lo cierras detrás de OTP, el OAuth flow falla, porque GitHub no resuelve un challenge de Cloudflare Access./api/health. Healthchecks externos. Innecesario que pidan OTP./*, Allow + Email [email protected], sesión 24 horas, IdP One-time PIN.Los tres Bypass los creé antes de tocar el DNS para el catch-all, así durante el cutover los webhooks ya estaban contemplados. Después confirmé que el deploy de un proyecto con autoDeploy seguía funcionando con un commit de prueba y que el OAuth de GitHub abría correctamente.
El plan Free de Zero Trust trae One-time PIN por email de serie. No requiere configuración, no necesita IdP externo, y para una persona con un solo email autorizado es perfectamente suficiente. Llega un código de 6 dígitos al correo, lo metes y entras.
Configuré la sesión a 24 horas. Es un equilibrio razonable, durante una jornada de trabajo no me piden OTP cada cinco minutos, y al día siguiente vuelve a pedir identidad. Con 1 sola persona en la cuenta el plan Free me sobra (admite hasta 50 usuarios).
El toggle de Aplicar autenticación instantánea que aparece en la UI no aporta nada cuando solo tienes un IdP activo, porque en ese caso CF aplica instant auth implícitamente y el formulario salta directo al campo de email sin pasar por una pantalla intermedia. Lo dejé OFF en los tres paneles y funciona igual. Solo lo activaría si en el futuro añado Google SSO y quiero forzar uno por defecto.
El test que hago tras cada migración es siempre el mismo, una pasada de curl que distingue Bypass de catch-all. El parámetro ?nocache=$(date +%s) es importante, evita que un caché intermedio devuelva un 200 antiguo y te haga creer que la regla de Access no está activa.
# Bypass paths, deben dar 200 directo del origen
curl -sI "https://analytics.tu-dominio.com/script.js?nocache=$(date +%s)"
curl -sI "https://dokploy.tu-dominio.com/api/health?nocache=$(date +%s)"
# Catch-all, debe dar 302 a Access
curl -sI "https://dokploy.tu-dominio.com/?nocache=$(date +%s)"
# Esperado, location: https://tu-cuenta.cloudflareaccess.com/...
Si el catch-all no devuelve un 302 a cloudflareaccess.com sino que sirve el HTML del panel, algo no aplicó. Lo más habitual es que el orden de las Applications no sea el correcto, o que el bypass tenga un path mal copiado.
Esta me costó cinco minutos de confusión la primera vez. Si el host ya tiene un registro A o CNAME en la zona (porque hasta ahora pasaba por Traefik), al guardar el Public Hostname desde el dashboard del tunnel CF te da un error con un mensaje no muy claro sobre que el registro ya existe. La solución es ir a DNS > Records y borrar el registro viejo antes de guardar el Public Hostname. Una vez lo guardas, CF crea automáticamente el CNAME hacia el tunnel.
El detalle a recordar es que durante esos pocos segundos el host queda sin DNS. Para hosts no críticos no pasa nada, para algo como Dokploy donde tienes webhooks llegando a cada momento conviene hacerlo en una ventana tranquila o, mejor, primero crear las Bypass apps con un path absurdo de prueba para asegurarte de que estás en el orden correcto del flujo Access antes de mover el DNS de verdad.
El otro punto que merece atención es que los webhooks externos siguen llegando al edge de Cloudflare, simplemente vía CNAME al tunnel. Es decir, GitHub llama a https://dokploy.tu-dominio.com/api/deploy/github, CF resuelve a un endpoint del tunnel, el tunnel transporta la petición al servicio interno, el servicio responde, el tunnel devuelve la respuesta a CF, CF responde a GitHub. Cero puerto abierto en el VPS. Cero IP del VPS publicada. Y cero login expuesto.
El path se evalúa contra las Access Applications, hace match con el Bypass /api/deploy, política Bypass + Everyone, así que la petición pasa sin pedir identidad. La política aplica al método igual que al path, GitHub mandará un POST con cabeceras propias y firma HMAC, eso lo valida después el servicio interno. Cloudflare Access no entra en el proceso de autenticación del propio webhook. (Si ese servicio interno hace rate limit o verifica identidad por IP del cliente, ten presente el bug silencioso de cf-connecting-ip que conté en la entrega anterior, los webhooks viajan por IPs del edge de CF y la cabecera correcta es cf-connecting-ip.)
Mientras las labels de Traefik siguen en el compose del panel y el tunnel está activo en paralelo, el rollback es trivial.
Por eso la fase 6 (cutover de Traefik) la dejo a 24-48h, las labels son una red de seguridad inerte que no estorba mientras el tunnel funciona, y me deja un rollback rápido durante la ventana de observación.
Cero. Zero Trust Free admite hasta 50 usuarios, con OTP por email gratuito incluido. cloudflared es outbound only, así que tampoco hay coste de tunnel, era una limitación del antiguo Argo Tunnel y CF la quitó hace años. El tunnel es resiliente al cambio de IP del VPS, si mañana cambio de proveedor de hosting y el VPS arranca con otra IP pública, los paneles siguen siendo accesibles porque el tunnel se conecta saliente al edge de CF, no depende de qué IP tiene el origen.
El único límite que sí miro es el de Applications activas por cuenta, en Free es 50. Hoy uso 7 (4 catch-alls + 3 bypass), no es estrecho.
El panel que aún no migré es el de n8n. Es el más delicado de los tres porque sus webhooks no son de un proveedor concreto, son de cualquier integración que apunte a /webhook/* o /webhook-test/*. Bypass amplio en esos prefijos y catch-all en el resto será el mismo patrón, pero quiero hacer un inventario antes de mover la pieza, no vaya a ser que alguno de mis flujos use un path no estándar.
Para el home lab, donde el tráfico es más personal y no me apetece que circule por la edge de un tercero, no extiendo Cloudflare, voy con Pangolin como pieza self-hosted. Mismo modelo de túnel saliente con identidad delante, pero con todo el camino del dato bajo mi control.
Con esto cierro la serie de migración del VPS detrás de Cloudflare. Quedan piezas operativas (vigilar IPs banneadas en CrowdSec por Telegram, automatizar la rotación del token CF, tener una segunda VPS como worker remoto) pero pertenecen a su propia historia.

Jose, autor del blog
QA Engineer. Escribo en voz alta sobre automatización, IA y arquitectura de software. Si algo te ha servido, escríbeme y cuéntamelo.
¿Qué te ha parecido? ¿Qué añadirías? Cada comentario afina la siguiente entrada.
Si esto te ha gustado

ScamDetector combina inteligencia artificial, búsqueda de reputación de teléfonos y escaneo de URLs para ayudarte a identificar estafas digitales. Sin registro, sin datos almacenados.

Repaso completo de las medidas de seguridad que puedes aplicar a un VPS Linux: desde CrowdSec y el firewall hasta el hardening del kernel, pasando por SSH, Docker y las actualizaciones automáticas.

Nuestros posts viven en una base de datos SQLite. Si alguien accede a ella, puede cambiar cualquier artículo sin dejar rastro. Construimos un verificador externo con hashes SHA-256 y firma Ed25519 que vigila la integridad desde un segundo servidor.