Builds Rápidos y Caché Seguro: Sin Versiones Viejas

Builds Rápidos y Caché Seguro: Sin Versiones Viejas

Acelera tus builds de Docker y CI al máximo sin desplegar versiones viejas: claves de caché por contenido, invalidación determinista y capas en el orden correcto.

Por Omar Flores

Hay dos tipos de caché en el mundo del software. El primero es el caché que funciona: acelera las operaciones repetidas, se invalida exactamente cuando el contenido cambia, y nunca sirve datos obsoletos. El segundo parece funcionar: los builds terminan rápido, los deploys se completan sin errores, y todo indica que el sistema está bien — hasta que alguien reporta que la funcionalidad que desplegaste hace tres días no existe en producción.

El segundo tipo es más peligroso que no tener caché. Un build lento que siempre compila correctamente es predecible. Un build rápido que ocasionalmente despliega la versión anterior es una trampa que solo se descubre en el peor momento.

Este post es sobre cómo tener los dos: velocidad máxima e invalidación correcta garantizada.

Por qué el caché de builds se rompe

Antes de acelerar, hay que entender exactamente cómo el caché falla. Los errores de caché en builds tienen tres causas raíz, y cada una produce un síntoma distinto.

Clave de caché basada en tiempo, no en contenido. Si el caché se invalida por timestamp en lugar de por hash del contenido, un build que corre dos veces en el mismo minuto puede usar el caché de la primera ejecución aunque el código haya cambiado. La clave debe derivarse del contenido que se cachea — no del momento en que se ejecutó.

Caché que incluye artefactos de más. Si el caché de node_modules incluye la carpeta dist/ del build anterior, el siguiente build puede usar esos artefactos en lugar de recompilarlos. El código nuevo está en src/, pero el ejecutable viejo está en dist/ — y el caché lo sirve sin que nadie lo note.

Orden incorrecto de capas en Docker. Si el COPY . . ocurre antes que el npm ci, cualquier cambio en cualquier archivo — incluyendo un cambio en .gitignore o en un archivo de configuración del editor — invalida la capa de instalación de dependencias. El resultado opuesto al que buscas: el caché se rompe cuando no debería, y no se invalida cuando sí debería.

El problema de desplegar versiones anteriores ocurre cuando la clave del caché es demasiado permisiva — reutiliza artefactos compilados aunque el input haya cambiado — o cuando el proceso de build no verifica que lo que sale del caché corresponde a lo que entró en él.

El principio de caché por contenido

La regla fundamental de cualquier caché seguro: la clave debe ser una función determinista del contenido que genera el artefacto cacheado.

Si el artefacto que cacheas es node_modules, la clave correcta es el hash de package-lock.json. Si package-lock.json no cambió, node_modules será idéntico. Si cambió aunque sea un byte, el caché se invalida y se reinstala todo.

# Clave incorrecta — timestamp o branch name
CACHE_KEY="node-modules-main-$(date +%Y%m%d)"

# Clave incorrecta — hash del directorio node_modules (circular)
CACHE_KEY="node-modules-$(md5sum node_modules)"

# Clave correcta — hash del archivo que define las dependencias
CACHE_KEY="node-modules-$(sha256sum package-lock.json | cut -d' ' -f1)"

# Para Go — hash del archivo que define el módulo
CACHE_KEY="go-modules-$(sha256sum go.sum | cut -d' ' -f1)"

# Para Python
CACHE_KEY="python-deps-$(sha256sum requirements.txt | cut -d' ' -f1)"

Cuando la clave de caché es el hash del lockfile, la relación es determinista y verificable: el mismo package-lock.json produce exactamente el mismo node_modules. Si alguien modifica el lockfile, el caché se invalida automáticamente y sin intervención manual.

Capas de Docker: el orden que lo determina todo

Docker cachea capas en orden secuencial. Cuando una capa cambia, todas las capas posteriores se invalidan. El objetivo es poner las capas que cambian raramente primero y las que cambian frecuentemente al final.

Pero hay una trampa que no es obvia: el COPY es la instrucción que más fácilmente invalida el caché, porque cualquier cambio en los archivos copiados rompe la capa.

# ANTIPATRÓN: una sola instrucción COPY invalida todo
FROM node:20-alpine
WORKDIR /app
COPY . .                       # ← Cualquier cambio en CUALQUIER archivo rompe aquí
RUN npm ci --frozen-lockfile   # ← Se reinstala en cada commit, aunque solo cambió src/
RUN npm run build

# CORRECTO: separar lo que cambia poco de lo que cambia mucho
FROM node:20-alpine
WORKDIR /app

# Paso 1: solo los manifiestos de dependencias — cambian raramente
COPY package.json package-lock.json ./

# Paso 2: instalar dependencias — cacheado hasta que los manifiestos cambien
RUN npm ci --frozen-lockfile

# Paso 3: el código fuente — cambia en cada commit
COPY . .

# Paso 4: compilar — siempre corre cuando el código cambia
RUN npm run build

Con el orden correcto, un commit que solo modifica src/ reutiliza la capa de npm ci. Solo cuando package.json o package-lock.json cambian se reinstalan las dependencias.

La trampa del COPY con archivos de configuración

Un error frecuente: copiar archivos de configuración junto con el código en un solo COPY . .. Si tienes .eslintrc, tsconfig.json, .prettierrc, y archivos de configuración que raramente cambian, combinarlos con el código en un COPY . . significa que cualquier ajuste de configuración invalida el caché del código.

# MÁS GRANULAR: separar configuración estable de código frecuente
FROM node:20-alpine
WORKDIR /app

# Nivel 1: manifiestos de dependencias (cambia: raramente)
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile

# Nivel 2: configuración del compilador (cambia: ocasionalmente)
COPY tsconfig.json tsconfig.build.json ./
COPY .eslintrc* ./

# Nivel 3: código fuente (cambia: en cada commit)
COPY src/ ./src/

RUN npm run build

Ahora cambiar tsconfig.json no reinstala dependencias — solo recompila. Cambiar src/ no toca ni dependencias ni configuración del compilador.

Cuándo NO usar caché en una capa

Hay capas que nunca deben cachearse porque su resultado correcto depende de factores externos al contenido copiado: actualizaciones de seguridad del sistema operativo, parches del runtime, índices de repositorios de paquetes.

# Actualizar el índice de paquetes SIEMPRE — no cachear
# Si cacheas esta capa, instalas versiones desactualizadas aunque el índice haya cambiado
RUN apt-get update && apt-get install -y \
    ca-certificates \
    curl \
    && rm -rf /var/lib/apt/lists/*

# Correcto para producción: no cachear la actualización del índice
# Usar --no-cache en Alpine garantiza que siempre obtiene la versión más reciente
RUN apk add --no-cache ca-certificates tzdata

--no-cache en Alpine equivale a apk update && apk add && rm -rf /var/cache/apk/*. No guarda el índice de paquetes en la imagen — cada build obtiene la versión más reciente disponible.

BuildKit: paralelismo real y caché inteligente

BuildKit es el motor moderno de Docker. Dos de sus funcionalidades tienen impacto directo en la velocidad sin comprometer la corrección del caché.

Etapas paralelas en multistage builds

BuildKit analiza el grafo de dependencias entre etapas y ejecuta las independientes en paralelo. Si tienes una etapa de tests y una etapa de build que no dependen entre sí, corren al mismo tiempo.

# syntax=docker/dockerfile:1.5
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile


# Esta etapa y 'test' son independientes — BuildKit las ejecuta en paralelo
FROM deps AS builder
COPY . .
RUN npm run build


# Corre en paralelo con 'builder' — no depende de su resultado
FROM deps AS test
COPY . .
RUN npm test


# Esta etapa espera a que 'builder' termine — no puede ser paralela
FROM nginx:alpine AS production
COPY --from=builder /app/dist /usr/share/nginx/html

Con BuildKit, builder y test corren simultáneamente usando la capa deps cacheada. Si tienes 4 núcleos en el runner de CI, el tiempo total es el máximo entre los dos, no la suma.

Cache mounts: dependencias persistentes entre builds

--mount=type=cache es la funcionalidad de BuildKit más potente para acelerar builds: persiste directorios de caché entre builds sin incluirlos en la imagen final.

# syntax=docker/dockerfile:1.5
FROM node:20-alpine AS builder
WORKDIR /app

COPY package.json package-lock.json ./

# --mount=type=cache persiste ~/.npm entre builds en el mismo runner
# El contenido NO entra en la imagen — solo acelera el download la próxima vez
RUN --mount=type=cache,target=/root/.npm \
    npm ci --frozen-lockfile

COPY . .
RUN npm run build
# Para Go — caché del módulo y del build cache
FROM golang:1.23-alpine AS builder
WORKDIR /app

COPY go.mod go.sum ./

RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

COPY . .

RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath -o /bin/server ./cmd/server

El go build cache (/root/.cache/go-build) es especialmente valioso. Go cachea los paquetes compilados entre builds. Si solo cambia cmd/server/main.go, Go recompila únicamente ese archivo y reutiliza todos los paquetes importados del caché. En proyectos grandes, esto reduce el tiempo de compilación de 3 minutos a 15 segundos.

La diferencia con el caché de capas regular: --mount=type=cache persiste entre builds incluso cuando la capa anterior cambió. El caché de capas normal se invalida en cascada — si COPY . . cambió, RUN go build se reconstruye desde cero aunque el módulo cache siga siendo válido. Con --mount=type=cache, el módulo cache está disponible siempre, independientemente de cuál capa lo precede.

La clave de seguridad: el caché de mount no contamina la imagen

Este es el punto crítico que hace que --mount=type=cache sea seguro: el directorio montado no existe en la imagen final. Si el caché tiene una versión vieja de un paquete pero el lockfile cambió, npm ci --frozen-lockfile descargará la versión correcta y la guardará en el caché, sobreescribiendo la anterior. El resultado en la imagen siempre corresponde al lockfile.

--frozen-lockfile y el equivalente en otros gestores son la garantía de corrección. No instalan lo que hay en caché — instalan lo que dice el lockfile, usando el caché para acelerar la descarga si la versión ya fue descargada antes.

GitHub Actions: caché que no miente

El caché de GitHub Actions tiene una propiedad importante: es inmutable por clave. Una vez que una entrada de caché existe para una clave dada, no puede sobreescribirse — solo puede crearse una entrada nueva con una clave diferente o la anterior expira.

Esta inmutabilidad es una garantía de seguridad: si tu clave es el hash del lockfile, el caché para esa clave siempre contiene exactamente lo que instalaste cuando lo creaste. No puede corromperse.

# .github/workflows/build.yml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Caché de node_modules — clave por hash del lockfile
      - name: Cache node_modules
        uses: actions/cache@v4
        id: npm-cache
        with:
          path: ~/.npm
          # La clave incluye el runner OS para evitar mezclar binarios de plataformas distintas
          key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
          # restore-keys: fallback a caché de versión anterior si no hay hit exacto
          # Útil para el primer build después de actualizar una dependencia
          restore-keys: |
            npm-${{ runner.os }}-

      - name: Install dependencies
        # npm ci SIEMPRE respeta el lockfile — el caché solo acelera la descarga
        # Nunca instala versiones diferentes a las del lockfile
        run: npm ci --frozen-lockfile

      - name: Build
        run: npm run build

      # Cachear el resultado del build — clave por hash del código fuente
      - name: Cache build output
        uses: actions/cache@v4
        with:
          path: dist/
          key: dist-${{ runner.os }}-${{ hashFiles('src/**', 'tsconfig.json', 'package-lock.json') }}

hashFiles('src/**', 'tsconfig.json', 'package-lock.json') genera un hash que cambia si cambia cualquier archivo en src/, o si cambia tsconfig.json, o si cambian las dependencias. El caché del build es correcto porque su clave refleja todos los inputs que afectan el output.

La trampa del restore-keys y cómo evitarla

restore-keys permite usar un caché parcial cuando no hay hit exacto. Esto es útil para las dependencias — un npm ci con el caché de la versión anterior descargará solo los paquetes que cambiaron en lugar de todo.

Pero restore-keys es peligroso si se usa para el output del build. Si restauras dist/ de un build anterior y el build actual falla silenciosamente, desplegarás la versión anterior sin saber.

# CORRECTO: restore-keys solo para dependencias, nunca para artefactos de build
- name: Cache node_modules
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
    restore-keys: npm-${{ runner.os }}-    # ← restore-keys aquí es seguro

- name: Cache build output
  uses: actions/cache@v4
  with:
    path: dist/
    key: dist-${{ runner.os }}-${{ hashFiles('src/**', 'tsconfig.json') }}
    # ← Sin restore-keys aquí — si no hay hit exacto, reconstruir desde cero
    # Un dist/ de una versión anterior es más peligroso que un build lento

Para artefactos de build, prefiere un caché conservador: hit exacto o nada. Para dependencias, el caché parcial es seguro porque el gestor de paquetes verifica el lockfile de todas formas.

Caché de Docker en CI: el problema de las capas entre runners

Cuando GitHub Actions corre en un runner nuevo — que es el caso por defecto, cada job usa un runner efímero — el caché de capas local de Docker no existe. Cada build empieza desde cero.

La solución es exportar el caché de capas a un lugar persistente: el registry o el almacenamiento de caché de GitHub Actions.

- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    target: production
    push: ${{ github.ref == 'refs/heads/main' }}
    tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

    # Importar caché del build anterior
    cache-from: type=gha,scope=mi-app-production

    # Exportar caché del build actual para el siguiente run
    # mode=max: cachear todas las capas intermedias, no solo la final
    cache-to: type=gha,scope=mi-app-production,mode=max

mode=max es la diferencia clave. Sin él, solo se cachea la capa final de la imagen. Con mode=max, se cachean todas las capas intermedias — incluyendo la etapa builder con las dependencias instaladas. El siguiente build puede reutilizar la etapa builder aunque la etapa production cambie.

scope: aislar caches de builds independientes

El scope en el caché de GitHub Actions es crítico cuando tienes múltiples imágenes o etapas que se construyen en el mismo repositorio. Sin scope, los builds se contaminan entre sí.

jobs:
  build-api:
    steps:
      - uses: docker/build-push-action@v5
        with:
          context: ./api
          cache-from: type=gha,scope=api        # ← scope específico para la API
          cache-to: type=gha,scope=api,mode=max

  build-frontend:
    steps:
      - uses: docker/build-push-action@v5
        with:
          context: ./frontend
          cache-from: type=gha,scope=frontend    # ← scope específico para el frontend
          cache-to: type=gha,scope=frontend,mode=max

Sin scope, el caché del build de la API podría interferir con el del frontend. Los scopes garantizan que cada build solo usa y actualiza su propio caché.

Invalidación explícita: el botón de emergencia

A veces el caché está en un estado incorrecto y necesitas forzar una reconstrucción limpia. Tener un mecanismo de invalidación explícito es parte de un sistema de caché robusto.

En GitHub Actions: bust key manual

# Añadir una variable CACHE_VERSION al workflow
# Cambiarla invalida todos los caches del workflow inmediatamente
env:
  CACHE_VERSION: v2    # Incrementar este valor cuando necesites invalidar todo

jobs:
  build:
    steps:
      - name: Cache node_modules
        uses: actions/cache@v4
        with:
          path: ~/.npm
          # La versión forma parte de la clave — cambiarla invalida el caché
          key: npm-${{ env.CACHE_VERSION }}-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

Cuando el caché está en un estado desconocido — por un bug en el build que guardó artefactos incorrectos, por un cambio en la configuración del build que el hash de archivos no captura, o simplemente porque quieres garantizar una build limpia — cambias CACHE_VERSION: v2 a v3 y todos los caches se invalidan. El siguiente build es lento pero correcto.

En Docker: invalidación de capa con ARG

Si necesitas forzar la reconstrucción de una capa específica sin cambiar el código:

FROM node:20-alpine AS builder
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile

COPY . .

# ARG con valor por defecto — pasar un valor diferente desde el CLI invalida desde aquí
ARG BUILD_ID=default
RUN echo "Build: $BUILD_ID" && npm run build
# Build normal — usa caché si está disponible
docker build -t mi-app:latest .

# Forzar reconstrucción del paso de build con un ID único
docker build \
  --build-arg BUILD_ID=$(date +%s) \
  -t mi-app:latest .

BUILD_ID=$(date +%s) usa el timestamp Unix como identificador único. Docker ve que ARG BUILD_ID tiene un valor diferente al cacheado y reconstruye desde esa instrucción hacia adelante. Las capas de instalación de dependencias siguen siendo válidas.

Verificar que el build produjo lo correcto

Un caché correcto no garantiza un build correcto si el proceso de build en sí tiene bugs. La verificación del output es la última capa de seguridad.

FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile
COPY . .
RUN npm run build

# Verificar que el build produjo los archivos esperados
# Si alguno falta, el build falla aquí — no en producción
RUN test -f dist/index.html || (echo "ERROR: dist/index.html no encontrado" && exit 1)
RUN test -f dist/assets/main.js || \
    ls dist/assets/*.js > /dev/null 2>&1 || \
    (echo "ERROR: No se encontró el bundle JavaScript" && exit 1)
RUN test -d dist/assets || (echo "ERROR: directorio dist/assets no encontrado" && exit 1)

FROM nginx:alpine AS production
COPY --from=builder /app/dist /usr/share/nginx/html

Si el build falló silenciosamente — TypeScript compiló con errores que se ignoraron, el output de Vite fue incompleto, un plugin falló y generó archivos parciales — estas verificaciones lo detectan antes de que la imagen se publique.

En GitHub Actions: comparar el hash del artefacto con el commit

- name: Build
  run: npm run build

# Guardar el hash del output junto al tag del commit
- name: Verify build freshness
  run: |
    BUILD_HASH=$(find dist/ -type f -exec sha256sum {} \; | sort | sha256sum | cut -d' ' -f1)
    echo "BUILD_HASH=$BUILD_HASH" >> $GITHUB_ENV
    echo "COMMIT_SHA=${{ github.sha }}" >> $GITHUB_ENV
    echo "Build hash: $BUILD_HASH for commit: ${{ github.sha }}"

# El tag de la imagen incluye el SHA del commit — nunca un tag mutable como 'latest' solo
- name: Build and push Docker image
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    # SHA del commit como tag primario — inmutable, trazable
    tags: |
      ghcr.io/${{ github.repository }}:${{ github.sha }}
      ghcr.io/${{ github.repository }}:latest
    labels: |
      org.opencontainers.image.revision=${{ github.sha }}
      org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}

El tag con el SHA del commit es la garantía final: si sabes qué commit está en producción, puedes verificar exactamente qué código se desplegó. latest es conveniente pero ambiguo — puede apuntar a cualquier build. El SHA del commit es inmutable.

El pipeline completo: rápido y correcto

Integrando todas las técnicas en un pipeline real que garantiza velocidad sin sacrificar corrección:

# .github/workflows/deploy.yml
name: Build and Deploy

on:
  push:
    branches: [main]

env:
  CACHE_VERSION: v1    # Incrementar para invalidar todos los caches
  REGISTRY: ghcr.io
  IMAGE: ghcr.io/${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ github.sha }}

    steps:
      - uses: actions/checkout@v4

      # 1. Caché de dependencias — clave por lockfile
      - name: Cache npm
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: npm-${{ env.CACHE_VERSION }}-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
          restore-keys: npm-${{ env.CACHE_VERSION }}-${{ runner.os }}-

      # 2. Tests antes de construir la imagen — fallar rápido
      - run: npm ci --frozen-lockfile
      - run: npm test
      - run: npm run type-check    # Si hay errores de tipos, no construir la imagen

      # 3. Login al registry
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # 4. BuildKit habilitado
      - uses: docker/setup-buildx-action@v3

      # 5. Build con caché externo — scope aislado por imagen
      - uses: docker/build-push-action@v5
        with:
          context: .
          target: production
          push: true
          # SHA del commit como tag primario — trazable e inmutable
          tags: |
            ${{ env.IMAGE }}:${{ github.sha }}
            ${{ env.IMAGE }}:latest
          # Metadatos para trazabilidad
          labels: |
            org.opencontainers.image.revision=${{ github.sha }}
            org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
          # Caché con scope específico y mode=max para capas intermedias
          cache-from: type=gha,scope=${{ github.workflow }}-production
          cache-to: type=gha,scope=${{ github.workflow }}-production,mode=max
          # Pasar el SHA como build arg — invalida caché de forma controlada si es necesario
          build-args: |
            BUILDTIME=${{ github.event.head_commit.timestamp }}
            VERSION=${{ github.sha }}

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_IP }}
          username: deploy
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            # Descargar la imagen nueva ANTES de detener la actual
            docker pull ${{ env.IMAGE }}:${{ needs.build.outputs.image-tag }}

            # Verificar que la imagen descargada corresponde al commit esperado
            ACTUAL_REVISION=$(docker inspect \
              --format='{{index .Config.Labels "org.opencontainers.image.revision"}}' \
              ${{ env.IMAGE }}:${{ needs.build.outputs.image-tag }})

            if [ "$ACTUAL_REVISION" != "${{ needs.build.outputs.image-tag }}" ]; then
              echo "ERROR: La imagen descargada no corresponde al commit esperado"
              echo "Esperado: ${{ needs.build.outputs.image-tag }}"
              echo "Actual:   $ACTUAL_REVISION"
              exit 1
            fi

            # Solo ahora reemplazar la versión en ejecución
            docker stop mi-app 2>/dev/null || true
            docker rm mi-app 2>/dev/null || true
            docker run -d \
              --name mi-app \
              --restart unless-stopped \
              -p 8080:8080 \
              ${{ env.IMAGE }}:${{ needs.build.outputs.image-tag }}

            # Limpiar imágenes sin tag — las versiones anteriores
            docker image prune -f

La verificación en el deploy — docker inspect para comparar el label revision con el SHA esperado — es la garantía final. Si por alguna razón el registry sirvió una imagen diferente a la esperada, el deploy falla con un mensaje claro antes de interrumpir el servicio en ejecución.

Lo que esto significa en la práctica

Un equipo que implementa estas técnicas no solo tiene builds más rápidos — tiene builds predecibles. Sabe exactamente qué determina cuándo se invalida el caché, puede verificar en cualquier momento qué código está corriendo en producción, y tiene un mecanismo de invalidación explícito para los casos donde el caché necesita ser forzado.

La velocidad es una consecuencia de tener el caché correcto, no el objetivo en sí. Un build de 45 segundos que siempre despliega el código correcto es infinitamente más valioso que un build de 15 segundos que ocasionalmente despliega la versión anterior.

He visto equipos pasar días buscando bugs en producción que resultaron ser código viejo cacheado en algún punto del pipeline. El costo de ese debugging supera años de builds lentos. La corrección del caché no es una optimización — es un requisito.

Un caché que nunca falla no es el más rápido. Es el que tiene claves derivadas del contenido, invalidación explícita cuando se necesita, y verificación del output antes del deploy. La velocidad viene sola cuando las claves son correctas.