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.
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.