Efficient image delivery: creating your own CDN
One Express server is an image origin, not a global CDN. This guide builds a bounded public image origin with Sharp and Redis. Put a CDN in front of it for edge delivery and TLS, and apply an appropriate abuse-control policy.
Prerequisites
Use Node.js 24 or newer and Redis 7 or newer. Publish only reviewed, immutable JPEG or PNG files in a directory that application users cannot modify. This example has no upload or authentication route.
Benefits of using a CDN for images
A CDN can reduce repeat transfers from your origin and bring cached responses closer to readers. Resizing before delivery avoids sending a full-resolution image to a small display. Measure both effects with your files and locations: an origin-side Redis hit is not an edge-cache hit.
How a CDN works (in 60 seconds)
On a miss, the CDN requests a variant from your origin. The origin validates the request, reads an approved source, processes it and returns a cacheable response. Subsequent requests may hit either the edge cache or Redis. Every cache must distinguish source versions, dimensions and formats.
Essential components for a custom image CDN
Separate private source storage, an explicit publication list, a bounded decoder, a disposable result cache and a delivery layer. Redis is not the source of truth. Do not use public caching for private files.
Set up the project
Create 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" }
}
Run npm install and commit the generated package-lock.json; subsequent installations can use
npm ci. Create an images directory containing your own reviewed file named photo-v1.jpg.
Start a disposable local Redis cache with a memory limit:
redis-server --bind 127.0.0.1 --maxmemory 128mb --maxmemory-policy allkeys-lru
Build a minimal image optimizer
Put this complete module in optimizer.js. The caller supplies a filename from its publication map,
not an arbitrary URL. The realpath check also rejects sources outside the publishing directory.
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()
}
The result fits within the requested square without enlargement or a changed aspect ratio. JPEG composites transparency onto white; WebP can preserve transparency.
Wire up Express with caching and security
Put this application in server.js. An omitted f negotiates WebP or JPEG. The resolved format,
not just the request URL, is part of the Redis key.
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
})
Start it with npm start. Redis is private, trusted infrastructure, not a user-writable cache. On a
cache failure the origin returns 503 instead of accepting an unlimited burst of uncached work.
Each cache command has a two-second deadline. A failed connection stays closed; restart this example
after Redis recovers. Production deployments need readiness monitoring and a supervised recovery policy.
The two-request cap includes cache access and is per process. Memory eviction bounds retained cache entries; decoding still
needs operating-system memory and CPU limits. These limits do not sandbox native image libraries.
Integrate any object-storage provider
A publisher can stage approved source objects in the read-only image directory. Alternatively, replace the loader with a storage SDK implementation that enforces the same byte limit while streaming, pins an immutable version and cancels failed transfers. Do not fetch arbitrary user-provided URLs: that introduces SSRF and unbounded-download risks.
A presigned storage URL authorizes a storage object. It does not automatically sign your image-origin route or a separate CDN URL. Keep these authorization boundaries explicit.
Containerize for repeatable deployments
Use an explicit build context:
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"]
Build with docker build -t image-origin .. Supply REDIS_URL for private Redis on the container
network; container loopback is not your host's Redis. Publish port 3000 only to your trusted proxy
or loopback for testing. The explicit copies exclude .env files and unrelated source files.
Scale horizontally with a load balancer
Instances can share immutable sources and Redis, but each has its own processing cap. Set fleet-wide
limits at the gateway and configure deadlines. Do not blindly enable trust proxy: an incorrect
forwarded-header policy lets clients spoof their address.
Configure the CDN to include width and explicit format in its key. If it does not honor
Vary: Accept, require an explicit f at that layer instead of caching negotiated responses
under a shared key. Do not cache error responses.
Monitor performance
Track cache hits, misses, latency, active transforms and rejected requests without logging signed URLs or image content. Use new filenames for published changes; changing a backing object alone cannot invalidate browser caches. Increment the encoder-policy key when output settings change.
Load-test with autocannon
First verify the same URL with different negotiated formats:
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
Decode the files and check format and dimensions; MIME headers alone do not detect a wrong cached body. Run a load test only against infrastructure you own:
npx autocannon -c 4 -d 10 'http://127.0.0.1:3000/images/photo.jpg?w=320&f=webp'
Measure cold and warm cases separately. Overload responses are not successful transforms, and high request throughput alone does not establish acceptable resource use.
Wrap-up
You now have an image origin and a variant-aware cache. Global edge delivery, secure publishing, operational limits and monitoring remain separate responsibilities. Transloadit's image processing and Smart CDN provide managed processing and delivery options.
