Last updated: May 1, 2025

<span aria-hidden="true" id="efficient-image-delivery-creating-your-own-cdn"></span>

# Efficient image delivery: creating your own CDN

![Tim Koschützki](/assets/images/teammates/avatar-tim-kos-1.jpg?dpl=dpl_DM8JrFduDL1oLK4J3qc9o5gMffMy)

**Tim Koschützki**

Co-founder · Berlin, Germany · Show bio

[](https://x.com/tim%5Fkos)[](https://github.com/tim-kos)

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.

<span aria-hidden="true" id="prerequisites"></span>

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

<span aria-hidden="true" id="benefits-of-using-a-cdn-for-images"></span>

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

<span aria-hidden="true" id="how-a-cdn-works-in-60-seconds"></span>

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

<span aria-hidden="true" id="essential-components-for-a-custom-image-cdn"></span>

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

<span aria-hidden="true" id="set-up-the-project"></span>

## Set up the project

Create a `package.json` with modern dependency versions:

```json
{
  "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:

```bash
npm ci

```

<span aria-hidden="true" id="build-a-minimal-image-optimizer"></span>

## Build a minimal image optimizer

`src/middleware/optimizer.js`:

```javascript
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)
  }
}

```

<span aria-hidden="true" id="wire-up-express-with-caching-and-security"></span>

## Wire up Express with caching and security

`src/server.js`:

```javascript
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)

```

<span aria-hidden="true" id="integrate-any-object-storage-provider"></span>

## 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`:

```javascript
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:

```javascript
// 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

```

<span aria-hidden="true" id="containerize-for-repeatable-deployments"></span>

## Containerize for repeatable deployments

A production-ready `Dockerfile` keeps the runtime consistent across environments:

```dockerfile
# 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`).

```bash
# 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

```

<span aria-hidden="true" id="scale-horizontally-with-a-load-balancer"></span>

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

<span aria-hidden="true" id="monitor-performance"></span>

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

<span aria-hidden="true" id="load-test-with-autocannon"></span>

## Load-test with autocannon

Use a tool like `autocannon` to simulate traffic and identify performance limits before deploying to production.

```bash
# 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.

<span aria-hidden="true" id="wrap-up"></span>

## 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](/docs/robots/image-resize.md) Robot—it handles format negotiation, storage, and global delivery for you.

\#cdn#performance#image-optimization#content-delivery-service

### 👩‍💻 Join 20k+ developers

Sign up for our [monthly newsletter](/newsletters.md) to receive direct links to 3 exclusive tech — and 2 product updates. No less, no more.

Your email:

Get access

## File uploading and encoding. Made simple.

Transloadit streamlines file handling for developers, trusted by brands like Coursera and The New York Times. We’re known for a reliable API, top-notch support, and a strong commitment to open source, with projects like [Uppy⁠](https://uppy.io) and [Tus⁠](https://tus.io) setting standards in file processing.

[Sign up](/c/)[Book a Demo](https://survey.typeform.com/to/kRg47Xi5)

No credit card needed

Cancel anytime
