Entrega eficiente de imágenes: crea tu propio CDN
Un solo servidor Express es un origen de imágenes, no un CDN global. Esta guía construye un origen de imágenes público y acotado con Sharp y Redis. Coloca un CDN delante de él para la entrega en el borde y TLS, y aplica una política adecuada de control de abusos.
Requisitos previos
Usa Node.js 24 o una versión más reciente y Redis 7 o una versión más reciente. Publica únicamente archivos JPEG o PNG revisados e inmutables en un directorio que los usuarios de la aplicación no puedan modificar. Este ejemplo no tiene ninguna ruta de subida ni de autenticación.
Beneficios de usar un CDN para imágenes
Un CDN puede reducir las transferencias repetidas desde tu origen y acercar las respuestas en caché a los lectores. Redimensionar antes de la entrega evita enviar una imagen a resolución completa a una pantalla pequeña. Mide ambos efectos con tus propios archivos y ubicaciones: un acierto de Redis del lado del origen no es un acierto de la caché en el borde.
Cómo funciona un CDN (en 60 segundos)
Cuando hay un fallo de caché, el CDN solicita una variante a tu origen. El origen valida la solicitud, lee una fuente aprobada, la procesa y devuelve una respuesta almacenable en caché. Las solicitudes posteriores pueden resolverse desde la caché en el borde o desde Redis. Toda caché debe distinguir versiones de origen, dimensiones y formatos.
Componentes esenciales de un CDN de imágenes propio
Separa el almacenamiento privado de las fuentes, una lista de publicación explícita, un decodificador acotado, una caché de resultados desechable y una capa de entrega. Redis no es la fuente de verdad. No uses caché pública para archivos privados.
Configura el proyecto
Crea package.json:
{
"name": "image-origin",
"private": true,
"type": "module",
"scripts": { "start": "node server.js" },
"dependencies": { "express": "5.2.1", "redis": "6.2.1", "sharp": "0.35.4" }
}
Ejecuta npm install y haz commit del package-lock.json generado; las instalaciones posteriores pueden usar
npm ci. Crea un directorio images que contenga tu propio archivo revisado llamado
photo-v1.jpg.
Inicia una caché local de Redis desechable con un límite de memoria:
redis-server --bind 127.0.0.1 --maxmemory 128mb --maxmemory-policy allkeys-lru
Crea un optimizador de imágenes mínimo
Coloca este módulo completo en optimizer.js. Quien lo invoca proporciona un nombre de archivo de su mapa
de publicación, no una URL arbitraria. La comprobación de realpath también rechaza las fuentes que
estén fuera del directorio de publicación.
import { open, realpath } from 'node:fs/promises'
import { resolve, sep } from 'node:path'
import sharp from 'sharp'
const MAX_BYTES = 8 * 1024 * 1024
const MAX_PIXELS = 12_000_000
sharp.concurrency(1)
sharp.cache(false)
export async function optimize(filename, size, format) {
const root = await realpath(resolve('images'))
const path = await realpath(resolve(root, filename))
if (!path.startsWith(root + sep)) throw new Error('Source is outside the publishing directory.')
const handle = await open(path, 'r')
let data
try {
const stat = await handle.stat()
if (!stat.isFile() || stat.size === 0 || stat.size > MAX_BYTES) {
throw new Error('Source size is unsupported.')
}
data = Buffer.alloc(MAX_BYTES + 1)
let length = 0
while (length < data.length) {
const { bytesRead } = await handle.read(data, length, data.length - length, null)
if (bytesRead === 0) break
length += bytesRead
}
if (length === 0 || length > MAX_BYTES) throw new Error('Source size is unsupported.')
data = data.subarray(0, length)
} finally {
await handle.close()
}
const pipeline = sharp(data, { limitInputPixels: MAX_PIXELS, failOn: 'warning' })
const metadata = await pipeline.metadata()
if (!['jpeg', 'png'].includes(metadata.format) || (metadata.pages ?? 1) !== 1) {
throw new Error('Only single-frame JPEG and PNG sources are supported.')
}
pipeline.rotate().resize({ width: size, height: size, fit: 'inside', withoutEnlargement: true })
// Sharp strips source metadata by default, including EXIF/GPS.
return format === 'jpeg'
? pipeline.flatten({ background: 'white' }).jpeg({ quality: 80 }).toBuffer()
: pipeline.webp({ quality: 80 }).toBuffer()
}
El resultado se ajusta dentro del cuadrado solicitado sin ampliarlo ni cambiar la relación de aspecto. JPEG compone la transparencia sobre blanco; WebP puede conservar la transparencia.
Conecta Express con caché y seguridad
Coloca esta aplicación en server.js. Si se omite f, se negocia WebP o JPEG. El formato
resuelto, y no solo la URL de la solicitud, forma parte de la clave de Redis.
import express from 'express'
import { createClient } from 'redis'
import { optimize } from './optimizer.js'
const published = new Map([['photo.jpg', 'photo-v1.jpg']])
const sizes = new Set(['320', '640', '1280'])
const types = new Map([['webp', 'image/webp'], ['jpeg', 'image/jpeg']])
const CACHE_SECONDS = 3600
const app = express()
app.disable('x-powered-by')
const redis = createClient({
url: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
disableOfflineQueue: true,
socket: { connectTimeout: 2000, reconnectStrategy: false },
})
redis.on('error', () => console.error('Image cache connection failed.'))
let active = 0
async function cacheCommand(command) {
// Redis command timeouts only cover queued work; close a stalled in-flight connection too.
const timer = setTimeout(() => {
if (redis.isOpen) redis.destroy()
}, 2000)
try {
return await command()
} finally {
clearTimeout(timer)
}
}
function fail(res, status, message) {
return res.status(status).set('Cache-Control', 'no-store').type('text').send(message)
}
function sendImage(res, format, bytes) {
return res.type(types.get(format)).set('Cache-Control', `public, max-age=${CACHE_SECONDS}`)
.set('X-Content-Type-Options', 'nosniff').send(bytes)
}
app.get('/images/:name', async (req, res) => {
const filename = published.get(req.params.name)
if (!filename) return fail(res, 404, 'Image not found.')
const params = new URL(req.originalUrl, 'http://localhost').searchParams
if ([...params.keys()].some((key) => key !== 'w' && key !== 'f') ||
params.getAll('w').length > 1 || params.getAll('f').length > 1) {
return fail(res, 400, 'Unsupported image parameters.')
}
const size = params.get('w') ?? '640'
const requested = params.get('f')
const accepted = requested === null ? req.accepts(['image/webp', 'image/jpeg']) : null
const format = requested ?? (accepted === 'image/webp' ? 'webp' : 'jpeg')
if (requested === null && !accepted) return fail(res, 406, 'No supported image format.')
if (!sizes.has(size) || !types.has(format)) return fail(res, 400, 'Unsupported image variant.')
if (requested === null) res.vary('Accept')
const key = JSON.stringify(['image-v1', filename, size, format])
if (active >= 2) return fail(res, 503, 'Image processor is busy.')
active += 1
try {
const cached = await cacheCommand(() => redis.get(key))
if (cached !== null) {
return sendImage(res, format, Buffer.from(cached, 'base64'))
}
const bytes = await optimize(filename, Number(size), format)
await cacheCommand(() => redis.set(key, bytes.toString('base64'), { EX: CACHE_SECONDS }))
return sendImage(res, format, bytes)
} catch {
console.error('Image request failed.')
return fail(res, 503, 'Image is temporarily unavailable.')
} finally {
active -= 1
}
})
app.use((_req, res) => fail(res, 404, 'Route not found.'))
app.use((_error, _req, res, _next) => fail(res, 400, 'Request could not be processed.'))
async function main() {
await redis.connect()
const server = app.listen(3000, process.env.HOST ?? '127.0.0.1')
server.requestTimeout = 10_000
server.headersTimeout = 10_000
server.on('error', () => {
console.error('Image server could not start.')
if (redis.isOpen) redis.destroy()
process.exitCode = 1
})
let stopping = false
const stop = () => {
if (stopping) return
stopping = true
const deadline = setTimeout(() => {
server.closeAllConnections()
if (redis.isOpen) redis.destroy()
process.exit(1)
}, 15_000)
deadline.unref()
server.close(() => {
if (redis.isOpen) redis.destroy()
clearTimeout(deadline)
})
}
process.once('SIGTERM', stop)
process.once('SIGINT', stop)
}
main().catch(() => {
console.error('Image origin startup failed.')
if (redis.isOpen) redis.destroy()
process.exitCode = 1
})
Inícialo con npm start. Redis es infraestructura privada y de confianza, no una caché en la que los
usuarios puedan escribir. Ante un fallo de la caché, el origen devuelve 503 en lugar de aceptar una
ráfaga ilimitada de trabajo sin almacenar en caché.
Cada comando de caché tiene un plazo de dos segundos. Una conexión fallida permanece cerrada;
reinicia este ejemplo después de que Redis se recupere. Los despliegues en producción necesitan
monitoreo de disponibilidad y una política de recuperación supervisada.
El límite de dos solicitudes incluye el acceso a la caché y es por proceso. El desalojo por memoria
acota las entradas de caché retenidas; la decodificación sigue necesitando límites de memoria y de
CPU del sistema operativo. Estos límites no confinan las bibliotecas nativas de imágenes en un
espacio aislado.
Integra cualquier proveedor de almacenamiento de objetos
Un publicador puede preparar los objetos de origen aprobados en el directorio de imágenes de solo lectura. Como alternativa, sustituye el cargador por una implementación con el SDK de almacenamiento que aplique el mismo límite de bytes durante el streaming, fije una versión inmutable y cancele las transferencias fallidas. No descargues URL arbitrarias proporcionadas por los usuarios: eso introduce riesgos de SSRF y de descargas sin límite.
Una URL prefirmada de almacenamiento autoriza un objeto de almacenamiento. No firma automáticamente la ruta de tu origen de imágenes ni una URL de CDN independiente. Mantén explícitos estos límites de autorización.
Empaqueta en contenedores para despliegues repetibles
Usa un contexto de compilación explícito:
FROM node:24-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY optimizer.js server.js ./
COPY images ./images
USER node
ENV HOST=0.0.0.0
EXPOSE 3000
CMD ["node", "server.js"]
Compila con docker build -t image-origin .. Proporciona REDIS_URL para el Redis privado de la red de
contenedores; el loopback del contenedor no es el Redis de tu host. Publica el puerto 3000 solo
hacia tu proxy de confianza o hacia el loopback para pruebas. Las copias explícitas excluyen los
archivos .env y los archivos fuente no relacionados.
Escala horizontalmente con un balanceador de carga
Las instancias pueden compartir fuentes inmutables y Redis, pero cada una tiene su propio límite de
procesamiento. Define límites para toda la flota en la puerta de enlace y configura los plazos. No
habilites trust proxy a ciegas: una política incorrecta de cabeceras reenviadas permite que los
clientes falsifiquen su dirección.
Configura el CDN para que incluya el ancho y el formato explícito en su clave. Si no respeta
Vary: Accept, exige un f explícito en esa capa en lugar de almacenar en caché las
respuestas negociadas bajo una clave compartida. No almacenes en caché las respuestas de error.
Monitorea el rendimiento
Haz seguimiento de los aciertos y fallos de caché, la latencia, las transformaciones activas y las solicitudes rechazadas sin registrar URL firmadas ni el contenido de las imágenes. Usa nombres de archivo nuevos para los cambios publicados; cambiar solo el objeto subyacente no puede invalidar las cachés del navegador. Incrementa la clave de la política del codificador cuando cambien los ajustes de salida.
Haz pruebas de carga con autocannon
Primero verifica la misma URL con distintos formatos negociados:
curl --fail-with-body -H 'Accept: image/webp' 'http://127.0.0.1:3000/images/photo.jpg?w=320' -o photo.webp
curl --fail-with-body -H 'Accept: image/jpeg' 'http://127.0.0.1:3000/images/photo.jpg?w=320' -o photo.jpg
curl --fail-with-body -H 'Accept: image/webp' 'http://127.0.0.1:3000/images/photo.jpg?w=320' -o cached.webp
Decodifica los archivos y comprueba el formato y las dimensiones; las cabeceras MIME por sí solas no detectan un cuerpo incorrecto en caché. Ejecuta una prueba de carga solo contra infraestructura de tu propiedad:
npx autocannon -c 4 -d 10 'http://127.0.0.1:3000/images/photo.jpg?w=320&f=webp'
Mide por separado los casos en frío y en caliente. Las respuestas de sobrecarga no son transformaciones correctas, y un alto rendimiento de solicitudes por sí solo no demuestra un uso aceptable de los recursos.
Conclusión
Ahora tienes un origen de imágenes y una caché que distingue variantes. La entrega global en el borde, la publicación segura, los límites operativos y el monitoreo siguen siendo responsabilidades aparte. El procesamiento de imágenes y el Smart CDN de Transloadit ofrecen opciones gestionadas de procesamiento y entrega.
