TL;DR: Netlify en plan gratis te da 300 créditos al mes y cada publicación los gasta. Se me acabaron y empezó a decir “Skipped due to account credit usage exceeded”: el blog dejó de actualizarse. Lo pasé a Cloudflare Pages, que es gratis y permite uso comercial. Moví las funciones, el paywall cifrado, los formularios con aviso por WhatsApp y el dominio. Todo lo probé primero en una dirección de prueba y el cambio final fue editar un solo registro DNS. Costo: $0.
Este post se lo escribí a mi primo, que está empezando con sitios web. Si tú también tienes un sitio en Netlify y te llegó el aviso de créditos, esto es para ti.
El problema: “Skipped due to account credit usage exceeded”
Netlify cambió su plan gratis a un sistema de créditos: te dan 300 al mes y se gastan con cada deploy a producción, con el tráfico y con las funciones. Yo publico seguido (posts, ajustes de diseño) y a fin de mes se me acabaron.
No me cobraron nada (no tenía tarjeta), pero pasó algo peor: cada push a main quedaba “saltado”. El sitio seguía en línea, pero congelado en la última versión. Los créditos se renovaban hasta el día 12 del mes siguiente.
Así se veía mi panel de Netlify. Fíjate en las filas grises:
Captura real (cambié el nombre del equipo, del sitio y del dominio). Arriba, el aviso rojo de créditos; en la lista, cada publicación que intenté quedó en “Skipped”. La última que sí salió es la verde, “Published”.

¿Por qué Cloudflare y no Vercel?
Lo primero que pensé fue Vercel, pero su plan gratis (Hobby) es solo para uso personal no comercial. Mi blog tiene contenido de pago y promociona mi empresa, así que no cabía.
Cloudflare Pages en plan gratis:
- Sitio estático con tráfico y ancho de banda sin tope.
- Uso comercial permitido.
- Funciones (Pages Functions) con un límite diario muy generoso para un blog.
- Base de datos D1 (SQL) y almacenamiento KV también con capa gratis.
- Si algún día te pasas de un límite, no te cobra: esa función falla ese día y ya.
Los números exactos de cada límite cambian; revísalos en la página de precios de Cloudflare antes de empezar. Para un blog personal sobran por mucho.
Además, mi dominio ya estaba en Cloudflare (lo uso como DNS y caché), así que el cambio de dominio iba a ser sencillo.
Paso 0: hacer el inventario de lo que depende de Netlify
Un sitio estático se mueve fácil. Lo difícil es todo lo que depende de Netlify sin que te des cuenta. Antes de tocar nada, busqué en el código:
grep -rn "data-netlify\|@netlify/blobs\|getStore\|/.netlify/functions\|x-nf-" .
cat netlify.toml
ls netlify/functions
Esto encontré en mi blog y su equivalente en Cloudflare:
| En Netlify | Para qué lo usaba | En Cloudflare |
|---|---|---|
Build hugo && node scripts/seal.mjs |
Generar el sitio y cifrar lo premium | Igual, sin cambios |
| Netlify Functions | Desbloquear posts premium, panel /admin |
Pages Functions en functions/api/ |
| Netlify Blobs | Guardar los códigos de acceso | D1 (base SQL) |
| Netlify Forms | Formulario de contacto y del paywall | Una función propia /api/contacto |
Header x-nf-client-connection-ip |
Saber la IP para limitar intentos | Header cf-connecting-ip |
| Webhook para purgar caché | Ver los cambios al instante | Purga desde el script de deploy |
Paso 1: las funciones pasan a functions/api/
En Netlify una función se veía así:
// netlify/functions/unlock.mjs
export default async (req) => {
const body = await req.json();
// ...
return new Response(JSON.stringify({ ok: true }));
};
En Cloudflare Pages basta con ponerla en functions/api/unlock.js y exportar un handler por método. La ruta sale del nombre del archivo: /api/unlock.
// functions/api/unlock.js
export async function onRequestPost({ request, env }) {
const body = await request.json();
// los secretos ya no vienen de process.env, vienen de env
const clave = env.PREMIUM_MASTER_KEY;
// ...
return new Response(JSON.stringify({ ok: true }), {
headers: { 'Content-Type': 'application/json' }
});
}
Lo compartido entre funciones va en functions/_lib/ (el guion bajo evita que se publique como ruta). En el navegador solo cambié las direcciones: /.netlify/functions/unlock → /api/unlock.
Paso 2: el error que casi se me pasa (KV no sirve para contar)
Mis códigos de acceso premium tienen usos limitados: por ejemplo, 3 desbloqueos. En Netlify los guardaba en Blobs. Lo natural era pasarlos a KV, el almacén llave-valor de Cloudflare.
Lo hice, y en la prueba pasó esto: un código de 2 usos se pudo usar 3 veces.
La razón: KV es eventualmente consistente. Después de escribir, una lectura puede devolver el valor viejo durante unos segundos (hasta un minuto). El contador leía “0 usos” cuando en realidad ya iba en 1. Para un paywall eso es una fuga.
La solución fue usar D1, la base SQL gratis de Cloudflare, donde el descuento del uso es una sola operación atómica:
UPDATE codigos
SET uses = uses + 1, last_used_at = ?
WHERE code = ? AND revoked = 0 AND uses < max_uses;
Si el UPDATE no cambió ninguna fila, el código ya estaba agotado. Aunque lleguen dos desbloqueos al mismo tiempo, nunca pasan del límite. Hice lo mismo con el límite de intentos por IP (anti fuerza bruta), con un INSERT ... ON CONFLICT DO UPDATE.
Regla que me llevo: KV está bien para guardar y leer después. Para contadores, usos o límites, usa D1.
Paso 3: los formularios sin Netlify Forms
Netlify Forms es cómodo: pones data-netlify="true" en el <form> y Netlify guarda los envíos y dispara una función. En Cloudflare eso no existe, así que hice una función functions/api/contacto.js que:
- Recibe el formulario (
await request.formData()). - Revisa el campo trampa contra bots (honeypot): si viene lleno, responde “ok” y no hace nada.
- Guarda una copia del mensaje en KV (aquí sí da igual la consistencia).
- Me avisa por WhatsApp con CallMeBot. Si el mensaje viene del paywall, además genera un código de acceso para ese post y me lo manda listo para vender.
En el navegador, los fetch('/') de los formularios ahora apuntan a fetch('/api/contacto').
Paso 4: no romper lo que ya vendí
Mis posts premium van cifrados dentro del HTML público (AES-256-GCM). La función los descifra solo si el código es válido. Si al migrar cambiaba algo del cifrado, nadie que ya pagó podría leerlos.
Reescribí el descifrado con WebCrypto (crypto.subtle), que viene en Cloudflare sin configurar nada, y antes de seguir hice una prueba: descifrar los 13 artículos premium con la versión vieja y con la nueva y comparar. Salieron idénticos los 13, y con el slug equivocado la nueva rechaza, como debe.
Regla de oro: la clave maestra es la misma en Netlify y en Cloudflare. Si la cambias, los artículos ya publicados no se pueden abrir.
Paso 5: probar todo en una dirección de prueba
Cloudflare Pages te da gratis una dirección tu-proyecto.pages.dev. Ahí subí el sitio completo sin tocar el dominio real:
npx wrangler login # una vez, abre el navegador
npx wrangler d1 create mi-base
npx wrangler pages project create mi-blog --production-branch main
npx wrangler pages secret bulk secretos.json --project-name mi-blog
npx wrangler pages secret bulk secretos.json --project-name mi-blog --env preview
npx wrangler pages deploy public --project-name mi-blog
Ojo con la última línea de secretos: las vistas previas no heredan los secretos de producción. Hay que subirlos a los dos entornos.
Luego corrí un script con 18 pruebas: que las páginas abran, que lo premium vaya cifrado, que un código válido desbloquee, que uno de otro post, agotado o revocado se rechace, que el panel /admin pida contraseña, que el formulario funcione y que tras 12 intentos fallidos te bloquee. Las 18 en verde, y el WhatsApp de prueba me llegó al teléfono.

Paso 6: el cambio de dominio (y la trampa de la caché)
Con todo probado, el cambio real fue:
- En Cloudflare Pages → el proyecto → Custom domains → agregar
blog.mi-dominio.com. - En el DNS, editar el registro
CNAMEdeblog: donde decíami-sitio.netlify.app, ponermi-blog.pages.dev(con la nube naranja).
Captura real de mi panel (cambié el dominio y la cuenta por nombres de ejemplo). Así queda el registro después del cambio: un solo CNAME.
Y aquí caí en una trampa: después del cambio, la portada seguía saliendo de Netlify. Con curl -sI vi cf-cache-status: HIT y un header viejo de Netlify. Mi zona tiene una regla que guarda el HTML en caché, así que Cloudflare me seguía sirviendo la copia vieja.
La solución: purgar la caché de la zona. Desde ese día mi script de publicación purga solo después de cada deploy. (Si quieres entender bien esto de la caché, lo expliqué en dominios detrás de Cloudflare: caché y purga.)
Si algo hubiera salido mal, regresar era volver a poner el CNAME viejo: Netlify seguía intacto como respaldo.
Paso 7: apagar los builds de Netlify
Último detalle: cuando Netlify renueve los créditos, cada push a main volvería a construir el sitio allá y gastaría créditos en algo que ya nadie ve. Así que detuve los builds (es reversible):
netlify api updateSite --data '{"site_id":"<tu-site-id>","body":{"build_settings":{"stop_builds":true}}}'
El último deploy de Netlify sigue en línea, por si algún día hay que regresar.
Cómo publico ahora: una sola línea
Hice un script de PowerShell que hace todo en orden y se niega a publicar si algo está mal:
- Genera el sitio con Hugo.
- Cifra el contenido premium.
- Revisa que no quede nada premium sin cifrar y que no haya imágenes rotas.
- Sube a Cloudflare Pages.
- Purga la caché.
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-cloudflare.ps1 -Produccion
Sin -Produccion publica una vista previa en vista-previa.mi-blog.pages.dev para revisar antes.
Cada vez que lo corro, Cloudflare guarda el deploy con su propia dirección. Si algo sale mal, puedo regresar a uno anterior desde el panel:
Captura real del proyecto en Cloudflare Pages (nombres cambiados): arriba el dominio conectado, abajo cada publicación de esa tarde.
Importante: con este método (Direct Upload), un push a
mainya no publica solo. Si lo quieres automático, se puede agregar una GitHub Action que corra el mismowrangler pages deploy.
Las trampas, en corto
- KV no sirve para contar usos ni límites: usa D1 con operaciones atómicas.
- Los secretos de producción no llegan a las vistas previas: súbelos también con
--env preview. SSL handshake failureen*.pages.devjusto al crear el proyecto: el certificado tarda unos minutos, solo espera.- Después del cambio de DNS el sitio “sigue en Netlify”: es caché; purga la zona.
- Scripts
.ps1con acentos en PowerShell 5: guárdalos en UTF-8 con BOM o verás “revisión”. - Direct Upload no se convierte después a “conectado a Git”: si quieres push → deploy automático, decídelo al crear el proyecto o usa una GitHub Action.
Checklist para tu migración
- Inventario: busca todo lo que dice
netlifyen tu código. - Funciones →
functions/api/*.jscononRequestPost. - Blobs → D1 si hay contadores; KV solo para guardar y leer.
- Formularios → tu propia función con honeypot.
- Misma clave de cifrado (si usas una) y prueba que lo viejo se siga abriendo.
- Secretos en producción y en preview.
- Probar todo en
*.pages.devantes del dominio. - Cambiar el CNAME y purgar la caché.
- Detener los builds del sitio viejo y dejarlo como respaldo un par de semanas.
¿Cuánto me costó? $0. ¿Cuánto tiempo? Una tarde, con Claude Code haciendo el trabajo pesado y yo aprobando cada paso.