Danila (Dayfing)
Volver a publicaciones
3038 palabras15 min

Caché de un sitio estático con Nginx y Cloudflare: cabeceras y purga

Sirva los assets con huella en el nombre con Cache-Control: public, max-age=31536000, immutable, y sirva el HTML con no-cache para los navegadores más un TTL de edge corto y separado para Cloudflare. Haga el HTML elegible para caché con una Cache Rule, deje Browser Cache TTL en Respect Existing Headers y purgue el HTML como último paso de cada despliegue. Así el navegador revalida un documento pequeño en cada visita, el edge responde por sí mismo a la mayoría de esas comprobaciones y una nueva versión se ve en cuanto responde la llamada de purga.

Dos políticas de caché para dos tipos de archivos

Un build estático genera dos tipos de archivos. Los archivos con huella, como /_astro/index.3f9c1a.js, llevan un hash del contenido en el nombre. Cuando el contenido cambia, la URL cambia, así que una copia de la URL antigua puede quedarse un año en cualquier caché. El HTML, los feeds, los sitemaps y robots.txt mantienen URL estables, por lo que cualquier copia en caché puede quedar obsoleta.

Por eso los archivos con hash reciben la vida más larga y nunca necesitan purga. Las URL estables reciben una política de navegador que revalida y una política de edge que se puede purgar. La mayor parte de la velocidad viene del primer grupo: un visitante que vuelve descarga un documento HTML pequeño, a menudo como 304, y reutiliza todos los scripts, hojas de estilo y fuentes desde su caché local. Por eso la caché es una de las mejoras más baratas para Time to First Byte y Largest Contentful Paint, como explica la guía de Core Web Vitals.

Dos reglas de despliegue hacen seguro este esquema. Suba los nuevos archivos con hash antes de que el HTML cambie a la nueva versión. Conserve los archivos con hash de la versión anterior mientras un HTML que los referencia pueda seguir en alguna caché, porque una pestaña abierta antes del despliegue pedirá los nombres antiguos.

El TTL del navegador y el TTL del edge son relojes distintos

La caché del navegador pertenece al visitante, y nada de lo que haga después de la respuesta puede quitarle una entrada. La caché del edge pertenece a Cloudflare, y la API de purga la vacía en segundos. Por tanto, use un TTL de navegador largo solo para URL cuyo contenido nunca cambia. Para el HTML, no-cache permite al navegador guardar la página, pero exige una petición condicional antes de cada reutilización.

El TTL del edge puede venir de s-maxage, de Cloudflare-CDN-Cache-Control o CDN-Cache-Control, o de una Cache Rule. Como se puede purgar, el TTL del edge para HTML es una red de seguridad, no el mecanismo de actualización. Si una purga falla, la obsolescencia máxima en el edge es la vida útil más la ventana de stale-while-revalidate: con max-age=300, stale-while-revalidate=60, son 360 segundos.

Revise primero un ajuste de la zona. Browser Cache TTL vale cuatro horas por defecto en todos los planes, y Cloudflare sustituye los tiempos del origen inferiores a ese valor. Un HTML pensado para revalidarse podría entonces quedarse horas en los navegadores, fuera del alcance de cualquier purga. Ponga Browser Cache TTL en Respect Existing Headers, como describe la documentación de TTL de edge y navegador.

Cómo interpreta Cloudflare s-maxage, stale-while-revalidate y stale-if-error

Los navegadores ignoran s-maxage, así que Cache-Control: public, max-age=0, s-maxage=300 parece la cabecera obvia para HTML, y Cloudflare sí la usa como TTL de edge. La trampa está en la RFC 9111: s-maxage también implica la semántica de proxy-revalidate, de modo que una caché compartida no debe servir la respuesta caducada sin revalidarla antes.

En los planes Free, Pro y Business, Origin Cache Control está siempre activo y Cloudflare aplica esa regla. Su documentación de revalidación enumera s-maxage, must-revalidate, proxy-revalidate y no-cache como directivas que desactivan el servicio de copias caducadas. Junto a stale-while-revalidate, convierten UPDATING en EXPIRED, y el visitante espera al origen. Las mismas directivas hacen que Cloudflare ignore stale-if-error. Una cabecera como max-age=0, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400 obtiene el TTL de edge de 300 segundos y nada más.

Cuando stale-while-revalidate se aplica, la revalidación es asíncrona: la primera petición tras la caducidad recibe la copia caducada con cf-cache-status: UPDATING mientras Cloudflare la refresca en segundo plano. stale-if-error solo actúa ante respuestas 5xx del origen. Always Online desactiva ambas directivas, y los valores de TTL deben ser enteros.

Para dar políticas distintas al navegador y al edge sin s-maxage, use una cabecera dirigida. Cloudflare evalúa Cloudflare-CDN-Cache-Control, luego CDN-Cache-Control y después Cache-Control. Cuando hay una cabecera de CDN, Cache-Control llega intacta al navegador y no afecta al edge, y Cloudflare no reenvía Cloudflare-CDN-Cache-Control. Para HTML:

Cache-Control: no-cache
Cloudflare-CDN-Cache-Control: max-age=300, stale-while-revalidate=60, stale-if-error=86400

El navegador revalida siempre. Cloudflare guarda la página cinco minutos, la sirve caducada hasta un minuto mientras la refresca y sirve la última copia válida durante un día si el origen falla. Las reglas de prioridad están en la documentación de CDN-Cache-Control.

Cache Rules: respetar el origen o sobrescribirlo

Por defecto, Cloudflare decide la elegibilidad por la extensión del archivo y no almacena en caché HTML ni JSON. Una URL como /writing/post/ no tiene extensión, así que sin una regla cada petición de página es DYNAMIC y va al origen.

Una Cache Rule con Eligible for cache ofrece tres modos de Edge TTL. respect_origin sigue sus cabeceras y, si faltan, aplica los valores por defecto, por ejemplo 120 minutos para una respuesta 200. bypass_by_default sigue las cabeceras y no guarda nada si faltan. override_origin ignora las cabeceras e impone un TTL, con un mínimo que depende del plan: 2 horas en Free, 1 hora en Pro y 1 segundo en Business y Enterprise. El Browser TTL puede respetar el origen, sobrescribirlo o saltarse la caché.

Cuando el origen envía cabeceras pensadas, respételas. Este ruleset para la fase http_request_cache_settings hace elegible el host y después excluye las rutas dinámicas. En los ajustes de caché gana la última regla que coincide, así que la regla de bypass va al final.

{
  "rules": [
    {
      "description": "Cache the site according to origin headers",
      "expression": "http.host eq \"example.com\"",
      "action": "set_cache_settings",
      "action_parameters": {
        "cache": true,
        "edge_ttl": { "mode": "respect_origin" },
        "browser_ttl": { "mode": "respect_origin" }
      }
    },
    {
      "description": "Never cache API routes and previews",
      "expression": "http.host eq \"example.com\" and (starts_with(http.request.uri.path, \"/api/\") or starts_with(http.request.uri.path, \"/preview/\"))",
      "action": "set_cache_settings",
      "action_parameters": { "cache": false }
    }
  ]
}

No añada http.request.method eq "GET" a una regla de caché. Cloudflare advierte que la purga de una URL concreta puede fallar cuando una regla solo coincide con GET, porque las peticiones de purga usan internamente otro método. La lista completa de opciones está en los ajustes de Cache Rules.

Cabeceras de Nginx sin la trampa de herencia de add_header

Nginx hereda las directivas add_header del nivel superior solo si el nivel actual no define ninguna. En cuanto un location añade su propio Cache-Control, todas las cabeceras del nivel server desaparecen allí en silencio, incluidas las de seguridad.

server {
    add_header X-Content-Type-Options "nosniff" always;

    location ^~ /_astro/ {
        # X-Content-Type-Options is no longer sent for this location.
        add_header Cache-Control "public, max-age=31536000, immutable";
    }
}

La solución portable es un snippet con las cabeceras comunes, incluido con include en cada bloque que tenga su propio add_header. Desde Nginx 1.29.3, y por tanto en la rama estable 1.30, add_header_inherit merge añade en su lugar las cabeceras heredadas. Con merge, deje Cache-Control fuera del bloque server, o los location la enviarán dos veces.

Según la documentación del módulo headers, add_header sin always solo se aplica a respuestas 200, 201, 204, 206, 301, 302, 303, 304, 307 y 308. Use always para las cabeceras de seguridad y para no-store en las páginas de error, nunca para cabeceras de caché de larga duración, o un 404 por una errata en el nombre de un asset quedará immutable durante un año. Evite también mezclar expires con add_header Cache-Control, porque produce dos campos de cabecera. Un bloque server para un build estático, sin TLS ni registros:

server {
    listen 443 ssl;
    http2 on;
    server_name example.com;
    root /srv/example.com/current;
    index index.html;

    gzip on;
    gzip_vary on;
    gzip_static on;
    gzip_types text/css application/javascript application/json
               application/xml application/rss+xml image/svg+xml text/plain;

    location ^~ /_astro/ {
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=31536000, immutable";
        try_files $uri =404;
    }

    location ~* \.(?:avif|webp|png|jpe?g|gif|svg|ico|woff2)$ {
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=86400";
        try_files $uri =404;
    }

    location ~* \.(?:xml|txt)$ {
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=300";
        add_header Cloudflare-CDN-Cache-Control "max-age=3600, stale-if-error=86400";
        add_header Cache-Tag "html";
        try_files $uri =404;
    }

    location / {
        include snippets/security-headers.conf;
        add_header Cache-Control "no-cache";
        add_header Cloudflare-CDN-Cache-Control "max-age=300, stale-while-revalidate=60, stale-if-error=86400";
        add_header Cache-Tag "html";
        try_files $uri $uri/ =404;
    }

    error_page 404 /404.html;
    location = /404.html {
        internal;
        include snippets/security-headers.conf;
        add_header Cache-Control "no-store" always;
    }
}

gzip_static requiere una compilación con --with-http_gzip_static_module, que se ve en la salida de nginx -V. El origen solo debe aceptar conexiones de Cloudflare, para que nadie se salte la caché. La guía de bastionado de un VPS Linux cubre la parte del cortafuegos.

ETag, Last-Modified y revalidación

Con etag on, activo por defecto, Nginx envía ETag y Last-Modified para los archivos estáticos y responde 304 a las peticiones condicionales. Su ETag se construye con la fecha de modificación y el tamaño, no con un hash del contenido. Por eso un despliegue que reescribe todos los archivos cambia todos los ETag, y la primera revalidación tras una versión devuelve un 200 completo. Si varios servidores de origen sirven la misma versión, sus archivos deben tener las mismas fechas, por ejemplo copiándolos con rsync -a, o el resultado de la revalidación dependerá del servidor que responda.

Mientras su copia está fresca, Cloudflare responde por sí mismo a las revalidaciones de los navegadores. Tras la caducidad envía una petición condicional a Nginx, y un 304 renueva el TTL sin transferir el cuerpo. Cuando Cloudflare cambia la codificación, debilita un ETag fuerte a W/"...", algo inocuo porque If-None-Match usa la comparación débil. Active Respect Strong ETags solo si un cliente necesita de verdad validadores exactos byte a byte.

Compresión: gzip en el origen, Brotli y Zstandard en el edge

Cloudflare pide al origen accept-encoding: br, gzip y puede recodificar lo que recibe. Hacia los visitantes sirve gzip, Brotli o Zstandard según Accept-Encoding, el plan y las Compression Rules. Por defecto, las zonas Free prefieren Zstandard, Pro y Business prefieren Brotli y Enterprise usa gzip. Solo se comprimen las respuestas 200, 403 y 404, como indica la documentación de compresión.

Para un origen detrás de Cloudflare, gzip basta. Precomprima los archivos de texto en el build para gzip_static, o deje que gzip on comprima al vuelo. gzip_types solo incluye text/html por defecto, así que enumere los demás tipos de texto y mantenga gzip_vary on. Brotli y Zstandard no forman parte de los módulos de nginx.org y requieren módulos de terceros. Evite Cache-Control: no-transform en el texto, porque impide que Cloudflare comprima una respuesta sin comprimir.

Cookies, query strings y la clave de caché

La clave de caché por defecto de Cloudflare contiene el esquema, el host, la ruta y la query string completa, además de algunas cabeceras de petición como Origin. Las cookies no forman parte de ella, así que una URL en caché nunca debe variar según una cookie. Ponga las páginas con sesión iniciada, los carritos, una API o el backend de un agente detrás de una regla de bypass con private o no-store, o en un host aparte. La guía de arquitectura de agentes de IA en producción muestra lo que suele contener esa ruta dinámica.

Una cabecera de respuesta Set-Cookie rompe la caché. Con Eligible for cache, Cloudflare conserva la cookie y no guarda la respuesta, así que cada petición es un MISS. Compruebe que ningún módulo ni balanceador añade cookies a los archivos estáticos, o elimínelas con una Cache Response Rule.

Cada query string distinta es una entrada separada, así que los enlaces con ?utm_source= empiezan con un fallo de caché. Eso cuesta tasa de aciertos, no corrección. El nivel de caché Ignore Query String solo se aplica a extensiones de archivos estáticos, y las claves personalizadas por parámetro dependen del plan. Sort query string está disponible en todos los planes.

Estrategias de purga y purga desde CI

Desde abril de 2025 todos los planes tienen todos los métodos de purga:

  • Single URL elimina URL exactas, hasta 100 por petición (500 en Enterprise), cuando el build sabe qué cambió.
  • Prefix elimina todo lo que hay bajo una ruta como example.com/writing/, incluidas las variantes con query string.
  • Tag elimina todos los objetos cuya respuesta llevaba una cabecera Cache-Tag coincidente, que Cloudflare retira antes de que la vea el visitante. Etiquete el HTML, los feeds y los sitemaps con html, y una sola llamada purga todas las URL estables mientras los assets con hash siguen en caché.
  • Hostname elimina todo lo de un host.
  • Everything vacía toda la zona y manda todo el tráfico al origen hasta que la caché se rellena, así que resérvela como último recurso.

Las purgas por hostname, tag, prefix y everything comparten un límite por cuenta: 5 peticiones por minuto en Free, 5 por segundo en Pro, 10 por segundo en Business y 50 por segundo en Enterprise, según la documentación de purga. Use un token de API limitado al permiso Cache Purge en una sola zona y guárdelo como secreto de CI:

#!/usr/bin/env bash
set -euo pipefail
: "${CF_API_TOKEN:?}" "${CF_ZONE_ID:?}" "${ORIGIN_HOST:?}"

# 1. Hashed assets first, without --delete, so old pages keep working.
rsync -a dist/_astro/ "deploy@${ORIGIN_HOST}:/srv/example.com/current/_astro/"

# 2. Everything else. Excluded paths are not deleted.
rsync -a --delete --exclude '/_astro/' dist/ "deploy@${ORIGIN_HOST}:/srv/example.com/current/"

# 3. Purge HTML, feeds and sitemaps at the edge.
curl -fsS --max-time 30 -X POST \
  "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"tags":["html"]}' </dev/null \
  | jq -e '.success == true' >/dev/null

Los sitios más grandes deberían subir el build a un directorio de versión y cambiar un enlace simbólico de forma atómica. Una respuesta de purga correcta solo significa que la petición se aceptó, así que termine el job descargando una página modificada y comprobando una marca del build, como el hash del commit.

Verificar con curl -I, cf-cache-status y Age

curl -sSI -H 'Accept-Encoding: zstd, br, gzip' https://example.com/writing/some-post/ \
  | grep -iE '^(cache-control|cf-cache-status|age|etag|content-encoding):'

Cloudflare convierte HEAD en GET para las peticiones cacheables, así que curl -I también llena la caché. La primera respuesta debe mostrar MISS y la segunda HIT con Age, los segundos transcurridos desde que el objeto se guardó o se revalidó. La documentación de respuestas de caché define cada estado:

  • DYNAMIC: la petición no era elegible, normalmente porque ninguna regla cubre el HTML o Development Mode está activo.
  • BYPASS: la petición era elegible, pero no-store, private, Set-Cookie o Vary: * hicieron que la respuesta no fuera cacheable.
  • UPDATING: se sirvió una copia caducada durante una revalidación en segundo plano.
  • EXPIRED: la copia caducada se volvió a pedir de forma síncrona. Si esperaba UPDATING, busque s-maxage, must-revalidate o no-cache.
  • REVALIDATED: el origen confirmó la copia con un 304 mientras la petición esperaba.
  • STALE: el origen no respondió y se sirvió la copia antigua.

Tras una purga, espere MISS, o EXPIRED con Tiered Cache y después de purge everything. Para probar solo Nginx, consulte el origen desde un host autorizado con curl -sSI --resolve example.com:443:203.0.113.10 https://example.com/, después añada -H 'If-None-Match: "<etag>"' y espere un 304.

Cabeceras recomendadas por tipo de archivo

Tipo de archivo Ejemplo Cache-Control Política de edge Tras un despliegue
JS, CSS, fuentes e imágenes con huella /_astro/app.3f9c1a.js public, max-age=31536000, immutable Respetar el origen Nada
Páginas HTML /writing/post/ no-cache max-age=300, stale-while-revalidate=60, stale-if-error=86400 Purgar el tag html
Feeds, sitemaps, robots.txt /rss.xml public, max-age=300 max-age=3600, stale-if-error=86400 Purgar el tag html
Imágenes e iconos sin hash /favicon.ico public, max-age=86400 Respetar el origen Purgar la URL o renombrar
JSON que cambia entre despliegues /data/stats.json public, max-age=60 Respetar el origen o bypass Purgar la URL
Página 404 cualquier URL inexistente no-store con always Sin caché Nada
API, vistas previas, administración /api/ private, no-store Regla de bypass Nada

Los valores de edge con max-age van en Cloudflare-CDN-Cache-Control. Si guarda las respuestas 404 en el edge, etiquételas con html, o una página publicada más tarde quedará oculta tras un 404 en caché.

Lista de comprobación del despliegue

  • Los nombres de los assets contienen hashes del contenido, y nada con URL estable está marcado como immutable.
  • Cada bloque de Nginx con add_header incluye las cabeceras de seguridad, y always solo aparece en las cabeceras de seguridad y las respuestas de error.
  • El HTML envía no-cache junto con Cloudflare-CDN-Cache-Control, sin s-maxage al lado de stale-while-revalidate.
  • gzip_types cubre los tipos de texto, y las respuestas estáticas no llevan Set-Cookie.
  • Browser Cache TTL respeta las cabeceras existentes, y la regla de bypass va al final sin una condición limitada a GET.
  • La CI sube los assets antes que el HTML, conserva los archivos con hash antiguos, purga el tag html y falla si success no es true.
  • Una página muestra MISS, después HIT con Age y luego UPDATING tras el TTL del edge.
  • El origen solo acepta tráfico de Cloudflare.

Más publicaciones