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+) and docker-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

  1. Origin server—runs the image-processing logic (we will use Express).
  2. Object storage—the single source of truth for originals.
  3. Edge cache—Redis or a managed service (e.g., Cloudflare Workers KV) keeps hot files near the compute layer.
  4. Image optimizer—Sharp is the de-facto toolkit for JPEG, WebP, and AVIF transforms.
  5. Security & rate limiting—Helmet + Express-Rate-Limit harden the API surface.
  6. 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: Ensure app.set('trust proxy', 1) (or the appropriate number of hops for your infrastructure) is set in Express. This allows express-rate-limit and other middleware to correctly identify the client's IP address from the X-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.

  1. Response Time Header: We added the response-time middleware, which adds an X-Response-Time header to each response (e.g., X-Response-Time: 123.456ms). This is useful for quick checks during development or debugging.
  2. 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.
  3. Application Metrics: For deeper insights, expose application metrics.
    • Prometheus: Libraries like prom-client and express-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.

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.