Efficient image delivery: creating your own CDN

Building your own Content Delivery Network (CDN) for images can dramatically improve perceived performance—and keep bandwidth bills under control—while giving you full ownership of the delivery pipeline.
Prerequisites
- Basic familiarity with Node.js and the command line
- Node.js
^18.17.0
or>= 20.3.0
(required for Sharp 0.33+) - An account with any object-storage provider (e.g., Amazon S3, Google Cloud Storage, MinIO)
docker
(24+) anddocker-compose
if you want to containerize
Benefits of using a CDN for images
- Reduced origin load—edge caches absorb most image traffic.
- Lower latency—files are served from locations close to visitors.
- Better Core Web Vitals & SEO—fast pages rank higher.
- Elastic scaling—handle traffic spikes without scrambling for new servers.
- Tailored optimization—resize and re-encode per device or network.
How a CDN works (in 60 seconds)
A CDN is a mesh of geographically distributed edge servers. When a user requests an asset, a global Domain Name System (DNS) routes them to the nearest edge. If the edge already has the file in its cache, it serves it immediately; otherwise it fetches it from the origin, stores it, and then returns it to the user. Subsequent regional requests hit the warmed cache and skip the round-trip.
Essential components for a custom image CDN
- Origin server—runs the image-processing logic (we will use Express).
- Object storage—the single source of truth for originals.
- Edge cache—Redis or a managed service (e.g., Cloudflare Workers KV) keeps hot files near the compute layer.
- Image optimizer—Sharp is the de-facto toolkit for JPEG, WebP, and AVIF transforms.
- Security & rate limiting—Helmet + Express-Rate-Limit harden the API surface.
- Observability—metrics and structured logs make it debuggable.
Set up the project
Create a package.json
with modern dependency versions:
{
"type": "module",
"scripts": {
"start": "node src/server.js",
"lint": "eslint . --ext .js"
},
"dependencies": {
"compression": "^1.7.4",
"express": "^4.18.3",
"express-rate-limit": "^7.1.5",
"helmet": "^8.1.1",
"redis": "^4.6.13",
"sharp": "^0.33.2",
"@aws-sdk/client-s3": "^3.496.0",
"response-time": "^2.3.2"
}
}
Install packages:
npm ci
Build a minimal image optimizer
src/middleware/optimizer.js
:
import sharp from 'sharp'
import path from 'node:path'
import fs from 'node:fs/promises' // Keep for potential local fallback/testing
const ALLOWED = new Set(['.jpg', '.jpeg', '.png', '.webp'])
export async function optimize(req, res, next) {
try {
const { image } = req.params
const ext = path.extname(image).toLowerCase()
if (!ALLOWED.has(ext)) return res.status(400).json({ error: 'Unsupported format' })
// In a real app, fetch the image from storage (e.g., S3 via pre-signed URL)
// For this example, we'll stick to local files for simplicity, but see the storage section.
const src = path.join(process.cwd(), 'images', image)
let imageBuffer
try {
imageBuffer = await fs.readFile(src)
} catch (err) {
if (err.code === 'ENOENT') {
return res.status(404).json({ error: 'Image not found' })
}
throw err // Re-throw other fs errors
}
const width = Number(req.query.width) || null
const quality = Number(req.query.quality) || 85
const format = req.accepts(['avif', 'webp', 'jpeg']) || 'jpeg'
let pipeline = sharp(imageBuffer)
.rotate() // Auto-rotate based on EXIF
.withMetadata() // Preserve metadata (like copyright)
if (width) {
pipeline = pipeline.resize({ width, withoutEnlargement: true })
}
switch (format) {
case 'avif':
pipeline = pipeline.avif({ quality, effort: 4 }) // effort 4 is a good balance
break
case 'webp':
pipeline = pipeline.webp({ quality })
break
default: // jpeg
pipeline = pipeline.jpeg({ quality, mozjpeg: true }) // Use mozjpeg for better compression
}
const optimizedBuffer = await pipeline.toBuffer()
res.type(`image/${format}`).send(optimizedBuffer)
} catch (err) {
// Pass errors to the central error handler
next(err)
}
}
Wire up Express with caching and security
src/server.js
:
import express from 'express'
import compression from 'compression'
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import { createClient } from 'redis'
import responseTime from 'response-time'
import { optimize } from './middleware/optimizer.js'
const app = express()
// Trust the first proxy hop (e.g., Load Balancer, Nginx) for IP detection
app.set('trust proxy', 1)
// ↳ Add response time header (X-Response-Time)
app.use(responseTime())
// ↳ Security headers via Helmet
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
// Allow images from self, data URIs, and blobs
imgSrc: ["'self'", 'data:', 'blob:'],
// Adjust other directives as needed for your specific application
},
},
crossOriginEmbedderPolicy: true, // Recommended for security
crossOriginOpenerPolicy: true, // Recommended for security
crossOriginResourcePolicy: { policy: 'cross-origin' }, // Allows cross-origin image requests
}),
)
// ↳ Generic rate limiting for the image endpoint
const imageLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: 1000, // Limit each IP to 1000 requests per windowMs
standardHeaders: 'draft-7', // Use RateLimit-* headers
legacyHeaders: false, // Disable X-RateLimit-* headers
message: { error: 'Too many requests, please try again later.' },
})
app.use('/images', imageLimiter)
// ↳ Enable gzip/brotli compression
app.use(compression())
// ↳ Connect Redis v4 for caching
const redisClient = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
})
redisClient.on('error', (err) => console.error('Redis Client Error:', err))
// Use a top-level async function to connect Redis
async function connectRedis() {
try {
await redisClient.connect()
console.log('Connected to Redis')
} catch (err) {
console.error('Failed to connect to Redis:', err)
// Optionally exit or implement retry logic
process.exit(1)
}
}
connectRedis() // Connect on startup
// ↳ Caching middleware
async function cache(req, res, next) {
const key = `img:${req.originalUrl}` // Use the full URL as the cache key
const requestedFormat = req.accepts(['avif', 'webp', 'jpeg']) || 'jpeg'
try {
const cachedData = await redisClient.get(key)
if (cachedData) {
console.log(`Cache HIT for ${key}`)
return res.type(`image/${requestedFormat}`).send(Buffer.from(cachedData, 'base64'))
}
} catch (err) {
console.error('Redis GET error:', err)
// Proceed without cache if Redis fails
}
console.log(`Cache MISS for ${key}`)
// Monkey-patch res.send to cache the response before sending
const originalSend = res.send.bind(res)
res.send = async (body) => {
// Only cache successful responses (2xx) and if body is a buffer
if (res.statusCode >= 200 && res.statusCode < 300 && Buffer.isBuffer(body)) {
try {
// Cache for 1 day (86400 seconds)
await redisClient.set(key, body.toString('base64'), { EX: 86400 })
} catch (err) {
console.error('Redis SET error:', err)
}
}
return originalSend(body) // Call the original send method
}
next()
}
// ↳ Image optimization route
app.get('/images/:image', cache, optimize)
// ↳ Centralized error handler
app.use((err, req, res, _next) => {
console.error('Error:', {
message: err.message,
stack: process.env.NODE_ENV !== 'production' ? err.stack : undefined, // Show stack only in dev
path: req.path,
method: req.method,
})
// Avoid sending stack trace in production
const statusCode = err.status || 500
const errorMessage =
process.env.NODE_ENV === 'production' && statusCode === 500
? 'Internal Server Error'
: err.message
res.status(statusCode).json({ error: errorMessage })
})
const port = process.env.PORT || 3000
app.listen(port, () => console.log(`Image CDN listening on :${port}`))
// Graceful shutdown
async function shutdown() {
console.log('Shutting down gracefully...')
try {
await redisClient.quit()
console.log('Redis connection closed.')
} catch (err) {
console.error('Error closing Redis connection:', err)
}
process.exit(0)
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
Integrate any object-storage provider
Instead of reading from the local filesystem (as shown in the basic optimizer.js
), a robust CDN
fetches originals from object storage. The recommended pattern is to generate pre-signed URLs
when images are initially uploaded. Your optimizer then fetches the image via this temporary, secure
HTTPS URL.
This approach decouples your optimizer from specific storage SDKs. Below is a small helper using the
AWS SDK v3 for Amazon S3, but the concept is identical for Google Cloud Storage, DigitalOcean
Spaces, or self-hosted MinIO. Ensure you have environment variables like AWS_REGION
and BUCKET
configured.
src/storage/get-presigned-url.js
:
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
// Configure the S3 client (reads credentials from environment or IAM role)
const s3 = new S3Client({ region: process.env.AWS_REGION })
export async function presign(key) {
const command = new GetObjectCommand({
Bucket: process.env.BUCKET,
Key: key, // The object key (e.g., 'uploads/my-image.jpg')
})
// Generate a URL valid for 60 seconds
try {
return await getSignedUrl(s3, command, { expiresIn: 60 })
} catch (error) {
console.error(`Error generating pre-signed URL for key ${key}:`, error)
throw new Error('Could not generate pre-signed URL')
}
}
In your optimizer.js
, you would replace the local filesystem read (fs.readFile
) with a fetch
call using the pre-signed URL:
// Import the presign function
import { presign } from '../storage/get-presigned-url.js'
// Inside the optimize function, replace fs.readFile:
// const imageBuffer = await fs.readFile(src);
let imageUrl
try {
// Assuming images are stored with a prefix like 'uploads/' in your bucket
imageUrl = await presign(`uploads/${image}`)
} catch (presignError) {
// Handle error generating the URL (e.g., permissions issue)
return next(presignError) // Pass to central error handler
}
const response = await fetch(imageUrl)
if (!response.ok) {
// Handle fetch errors (e.g., 403 Forbidden if URL expired, 404 Not Found)
const error = new Error(`Failed to fetch image: ${response.status} ${response.statusText}`)
error.status = response.status === 404 ? 404 : 500 // Set appropriate status
return next(error)
}
const imageBuffer = Buffer.from(await response.arrayBuffer())
let pipeline = sharp(imageBuffer) // Process the buffer fetched from storage
.rotate()
.withMetadata()
// ... rest of the pipeline
Containerize for repeatable deployments
A production-ready Dockerfile
keeps the runtime consistent across environments:
# Use an official Node.js lts version
FROM node:20-slim
WORKDIR /app
# Copy package files and install dependencies
COPY package*.json ./
# Use --omit=dev for production builds to reduce image size
RUN npm ci --omit=dev && npm cache clean --force
# Copy the rest of the application code
COPY . .
# Expose the port the app runs on
EXPOSE 3000
# Define the command to run the application
CMD ["node", "src/server.js"]
Spin it up locally. Make sure you have a .env
file with necessary variables (like REDIS_URL
,
AWS_REGION
, BUCKET
).
# Build the docker image
docker build -t image-cdn .
# Run the container, mapping port 3000 and passing environment variables from .env file
docker run -p 3000:3000 --env-file .env image-cdn
Scale horizontally with a load balancer
Because our server is stateless (state is managed by Redis and object storage), we can run multiple container replicas and distribute traffic using a load balancer (like Nginx, HAProxy, or a cloud provider's managed service). Key considerations:
trust proxy
: Ensureapp.set('trust proxy', 1)
(or the appropriate number of hops for your infrastructure) is set in Express. This allowsexpress-rate-limit
and other middleware to correctly identify the client's IP address from theX-Forwarded-For
header set by the load balancer/proxy.- Sticky Sessions: Generally not required for this stateless image optimization workflow. Standard round-robin or least-connections balancing is usually sufficient and preferred for better load distribution.
Monitor performance
Observability is crucial for understanding bottlenecks and ensuring reliability.
- Response Time Header: We added the
response-time
middleware, which adds anX-Response-Time
header to each response (e.g.,X-Response-Time: 123.456ms
). This is useful for quick checks during development or debugging. - Structured Logging: The updated error handler provides more structured logs. Consider using a dedicated logging library (like Pino or Winston) for consistent JSON logging, which is easier to parse, search, and analyze with log management tools.
- Application Metrics: For deeper insights, expose application metrics.
- Prometheus: Libraries like
prom-client
andexpress-prom-bundle
can automatically instrument Express and Node.js runtime metrics (event loop lag, heap usage) and expose a/metrics
endpoint compatible with Prometheus for scraping and Grafana for visualization. - Hosted APM (Application Performance Monitoring): Services like Datadog, New Relic, or Dynatrace offer Node.js agents that provide detailed performance monitoring, distributed tracing across services, and error tracking with minimal setup, often providing more out-of-the-box insights than manual Prometheus setup.
- Prometheus: Libraries like
Load-test with autocannon
Use a tool like autocannon
to simulate traffic and identify performance limits before deploying to
production.
# Test with 100 concurrent connections for 10 seconds, requesting WebP format at 500px width
npx autocannon -c 100 -d 10 -H 'Accept: image/webp' 'http://localhost:3000/images/test.jpg?width=500'
Analyze the results, paying attention to:
- Latency (p99, p95, avg): High percentiles reveal the worst-case performance experienced by users.
- Requests per second (RPS): Indicates the throughput capacity of a single instance.
- Non-2xx responses: Errors or rate limiting indicate problems under load (check server logs for details).
- Cache Hit Rate: Monitor Redis logs or use Redis monitoring commands (
INFO keyspace
) to see how effectively the cache reduces origin load and processing time.
Wrap-up
A handmade CDN gives you fine-grained control over caching rules, formats, and cost optimizations that off-the-shelf platforms sometimes hide behind premium plans. You now have the building blocks— an optimizer, an edge cache, object storage integration, containerization, and monitoring strategies—to create a blazing-fast, custom "cdn for images" setup tailored to your needs. This approach provides flexibility for image cdn optimization exactly how you want it.
Need a zero-maintenance alternative that still delivers perfectly optimized images worldwide? Check out Transloadit’s 🤖 /image/resize Robot—it handles format negotiation, storage, and global delivery for you.