Tengo dos apps en Laravel en producción que fueron afectadas por esto. PelotaYa ayuda a los dueños de canchas de fútbol en el Perú a llevar sus reservas, la caja y los que deben. Dóich enseña alemán a hispanohablantes, gratis, porque parece que me gustan los hobbies que vienen con factura de hosting.
Quería subirles el puntaje de PageSpeed y terminé aplicando cambios por otro lado. La mayoría de los que llegaba a los servidores no eran humanos. Un scanner le pidió a Dóich el archivo /.env.anthropic. Hay que reconocerle que está al día con las noticias.
En corto
| pelotaya.com | doichapp.com | |
|---|---|---|
| Hosting | Un VPS con Laravel Forge | Laravel Cloud, se apaga solo |
| PageSpeed, móvil | 69 → 97 | 78 → 100 |
| Largest Contentful Paint | 6.2 s → 2.6 s | 4.7 s → 0.9 s |
| Lo que me cuesta un bot | CPU | Dinero |
- La velocidad. Casi todo salió de la misma idea en las dos apps: Cloudflare sirve las páginas públicas como HTML ya listo, y el servidor ni se entera.
- Los bots. Los dos dominios pasan por mi propia zona de Cloudflare, que ve cada request antes que el servidor. Unas cuantas reglas bloquean a los scanners y le ponen un challenge al tráfico de países donde nadie usa las apps. En PelotaYa, cerca del ~80% de los requests ya no pasa de ahí.
- El costo. El plan gratuito de Cloudflare. No pagué por nada de ello.
Las dos apps están en la misma cuenta de Cloudflare, así que este es su tráfico sumado. Esos picos no son una campaña de marketing.
Los agentes revisaron los traces, los headers y los logs. Yo decidí qué se modificaba y discutí con ellos sobre qué cuenta como terminado. El resto del post es detalle, para el que quiera.
Por qué un bot cuesta dinero en una app y en la otra no
PelotaYa corre en un VPS que cuesta lo mismo todos los meses. Los microsites de cada negocio viven en {slug}.pelotaya.com, así que hay un registro DNS comodín, y a los scanners les encanta un comodín. Probaban api-staging, backend-dev, api-internal y compañía, unas mil veces cada uno al día. Cada intento llegaba a Laravel, que arrancaba, buscaba el subdominio en la base de datos y respondía un 404 con toda educación. La factura no cambia, pero un servidor chiquito gastaba CPU de verdad atendiendo a nadie.
Dóich corre en Laravel Cloud con scale-to-zero: la app se duerme cuando nadie la usa y el cómputo solo se cobra mientras está despierta. El detalle es que cualquier request la despierta, aunque termine en 404, y después se queda prendida unos cinco minutos. O sea, un scanner pidiendo /.env.production a las tres de la mañana me compraba cinco minutos de cómputo. A mi nombre.
Los dos últimos periodos completos salieron $10.16 y $9.41, y unos $4.50 de cada uno fueron cómputo, la línea que cobra el tiempo que el container está despierto. Los cambios salieron hoy, así que todavía no hay número del “después”. Lo agrego acá cuando cierre el próximo periodo, en noviembre.
Esa comparación también abre la siguiente pregunta. Ahora que el servidor de PelotaYa atiende casi solo a humanos y Cloudflare se encarga de casi todo lo demás, pasarlo a Laravel Cloud, donde estar sin hacer nada no cuesta, podría salir más barato que el VPS. Dóich es el ensayo.
pelotaya.com, con calma
El front tenía cuatro problemas:
- 200 archivos de JS que nadie pidió. Un paquete que uso había prendido, sin avisar, el prefetch agresivo de Vite. Cada visitante se bajaba unos 580 KB de JS, casi todo pantallas de administración que nunca va a ver. Una línea de configuración, y el salto más grande de todos.
- Fuentes de otro lado. Cuatro archivos desde un CDN externo bloqueaban el primer pintado. Ahora es una sola fuente variable alojada en el mismo servidor: 30 KB en vez de 72.
- El diccionario entero en cada página. Cada página traía el catálogo completo de traducciones, más de la mitad del HTML. La portada ahora manda solo los 11 grupos que usa, y un test se asegura de que siga así.
- Un
aria-labelinválido. Accesibilidad pasó de 96 a 100.
Lighthouse 13 además trae una categoría nueva, “agentic browsing”, que me bajó un punto porque /llms.txt no existía. Ahora existe. Sí, en un post sobre bloquear bots, agregué un archivo solo para bots.
Con eso el puntaje quedó entre 86 y 95, según la corrida. El techo era el servidor: unos 500 ms antes del primer byte, porque cada request pasaba por PHP.
La portada cargando en un celular lento simulado, a los 375, 1125, 1875 y 2250 ms. Arriba el antes, abajo el después:
Cachear el HTML fue la parte interesante, porque “cachear todo” no funciona con una app en Inertia:
- Laravel pone dos cookies en cada respuesta, y Cloudflare nunca cachea una respuesta que pone cookies.
- Inertia usa la misma URL para la página HTML completa y para el JSON que pide cuando navegas dentro de la app. El plan gratuito de Cloudflare ignora
Vary, así que una página HTML cacheada podía terminar respondiendo a un request de JSON, e Inertia dibujaba la página entera dentro de un modal. Creativo, pero no. - Una página cacheada no tiene sesión, así que el primer formulario enviado desde ahí fallaba con un 419, el “página expirada” de Laravel.
El plan gratuito puede saltarse la caché según una cookie o un header, pero no puede guardar versiones distintas según uno. Entonces: saltar, nunca variar. Solo se cachean la portada, los términos y la privacidad. Para un visitante anónimo, un middleware no abre sesión ni manda cookies:
cache-control: max-age=0, public, s-maxage=600, stale-while-revalidate=60cf-cache-status: HITQuien tenga una cookie de sesión, o cualquier request de Inertia, se salta la caché. La app pide una cookie CSRF a un endpoint chiquito justo antes del primer formulario, y el script de despliegue purga Cloudflare al final. Los tests revisan que una página cacheable no lleve cookies, ni usuario, ni token CSRF. Después rompí el middleware a propósito de cinco maneras distintas, y cada una hizo fallar un test.
El tiempo hasta el primer byte de la portada bajó de 450–900 ms a 16–60 ms, y el puntaje se quedó en 97.
Los scanners se ganaron tres reglas personalizadas, de las cinco que permite el plan gratuito. Dos bloquean unos 200 nombres típicos (entornos, paneles de administración, herramientas de CI, bases de datos) más patrones como api-* y staging-*. La tercera le pone un desafío administrado al tráfico de fuera del Perú, Alemania e Irlanda. Las personas lo pasan, casi siempre sin darse cuenta. Los scripts no. Los bots verificados, los webhooks y PageSpeed están exentos.
En los primeros 100 minutos, 3193 de unos 4020 requests se quedaron en Cloudflare. Solo Países Bajos mandó 2065. Mucho interés en alquilar una cancha en Lima. Algunos scanners se pasaron a nombres que no están en la lista, y el desafío por país los agarró igual. Por eso conviene tener las dos capas.
Unas horas después, la lista se fue. Una lista de bloqueo siempre va un nombre atrás: el primer día los scanners ya probaban digital. y review., que no estaban en ninguna lista. Así que le di la vuelta. Ahora una sola regla bloquea cualquier hostname *.pelotaya.com salvo www, los sitios de negocios ya publicados y los tenants de demo, que la regla reconoce por la forma de su nombre. Cada visitante que prueba la demo recibe su propio subdominio, así que listarlos nunca fue opción.
La regla se mantiene al día sola. Cuando un dueño publica su sitio por primera vez, la app encola un job que reescribe la regla con la API de Cloudflare, y el subdominio nuevo funciona en segundos. La misma sincronización corre en cada despliegue y una vez al día, por si acaso.
| Antes | Ahora | |
|---|---|---|
| Qué se bloquea | Unos 200 nombres que a alguien se le ocurrieron | Todo lo que no sea un sitio real |
| Reglas personalizadas | 3 de 5 | 2 de 5 |
Sigue sin costar ni un sol. El límite, para ser honestos: una regla alcanza para unos 120 sitios publicados, y pasado eso la app tendrá que partirla. Me preocupo cuando PelotaYa tenga ese problema, que sería un lindo problema.
doichapp.com, con calma
Lo de PageSpeed fue corto. La portada se dibujaba entera en el navegador: el HTML llegaba como un <div id="app"></div> vacío y el título esperaba 3.1 segundos a que cargara el JS. Renderizado en el servidor solo para la portada, CSS en línea, capturas en WebP y una animación de entrada menos. 100 en las cuatro categorías, con el LCP en 0.9 segundos.
Para ser justos, el puntaje de rendimiento todavía baila. Algunas corridas caen entre 72 y 84, por el trabajo que hace Vue después del primer pintado, medido en máquinas de prueba compartidas. El único 100 garantizado es sacar Vue de la portada, y eso no lo voy a hacer.
Lo que despertaba a la app era casi todo esto: /.env.dev, /.env.local, /.env.save, sitemaps de WordPress e intentos de explotar WordPress varias veces por segundo. En una app que no tiene WordPress. Cada uno recibía un 404 en 15–30 ms, que suena barato hasta que te acuerdas de que primero había que despertar a la app.
Dos reglas. La primera bloquea rutas de scanner desde cualquier país: /.env, /.git, /wp-, todo lo que termine en .php y cualquier POST a la portada. La app no tiene nada de eso. La segunda le pone el desafío al tráfico de fuera del Perú, Alemania, España e Irlanda, que es donde están los que de verdad aprenden, con los bots verificados, las vistas previas de enlaces y PageSpeed exentos. También apagué el hostname *.laravel.cloud de la app, que iba directo a Laravel Cloud y se saltaba mi zona de Cloudflare.
En la primera hora, alguien en Egipto fue por /xmlrpc.php y alguien en Rusia intentó instalar WordPress. Los dos bloqueados, y ninguno despertó a la app.
La caché usa el mismo truco que PelotaYa: la portada corre sin el middleware de sesión, así que no pone cookies y Cloudflare la guarda una hora. Los archivos del build y las fuentes se cachean un año, y el service worker nunca, porque un service worker viejo es la forma más rápida de trabar las actualizaciones de una PWA. Un comando de Artisan purga la zona de Cloudflare en cada despliegue. Un visitante sin sesión ahora recibe la portada entera desde Cloudflare, y Laravel Cloud sigue durmiendo.
Lo que falta
- PageSpeed es un puntaje de laboratorio. Google posiciona con datos de campo de usuarios reales de Chrome, que tardan unos 28 días en ponerse al día.
- PelotaYa no llega a 100. Lo siguiente es meter el CSS crítico en línea. Y hacer el build del front en otro lado, porque hacerlo en el mismo servidor chiquito provoca algunos 504 durante los despliegues.
- La factura de Dóich. Noviembre dirá si dormir de corrido de verdad ahorra plata.
Salí a perseguir un número para Google, y casi todo el trabajo terminó siendo decirles que no a los bots. Con educación, eso sí. Con un 403.