How to Set Up Nginx as a Content Delivery Network (CDN)

How to Set Up Nginx as a Content Delivery Network (CDN)

How to Set Up Nginx as a Content Delivery Network (CDN)

If you’ve ever waited for a website to load images or videos from halfway across the world, you already understand why CDNs exist. A Content Delivery Network puts copies of your static content closer to your visitors, cutting latency and taking pressure off your origin server. Most people assume you need a commercial service like Cloudflare or Akamai to get this benefit, but that’s not entirely true. With a handful of servers in different regions and a properly tuned Nginx configuration, you can build a lightweight CDN of your own — one that caches, compresses, and serves content fast, and one you fully control.

This guide walks through building that setup from scratch: the concept behind it, the server requirements, the actual configuration, testing, troubleshooting, security, and the kind of real-world tuning that separates a toy setup from something that can actually handle production traffic.

What “Nginx as a CDN” Actually Means

A commercial CDN is really three things bundled together: a global network of edge servers, a caching layer on each of those servers, and a routing system (usually DNS-based, sometimes Anycast) that sends visitors to the nearest edge. Nginx alone doesn’t give you global routing — you still need edge servers in different locations and some way to direct traffic to them (DNS geolocation routing, GeoDNS providers, or a service like Route 53’s latency-based routing work well here). What Nginx provides extremely well is the caching and serving layer that runs on each edge node.

So the architecture looks like this:

  1. An origin server where your actual content lives (your application server, object storage, or a static file host).
  2. One or more edge servers, each running Nginx configured as a reverse proxy with caching enabled, placed in different geographic regions.
  3. A DNS layer that routes visitors to the nearest or best-performing edge server.

Each edge server pulls content from the origin on the first request, caches it locally, and serves subsequent requests directly from cache — no round trip to the origin needed. This is precisely the proxy_cache mechanism in Nginx, and it’s the backbone of this whole setup.

Requirements Before You Start

You’ll need:

If you’re testing this locally before deploying to multiple regions, a single VPS is fine to validate the configuration — you just won’t get the geographic benefit until you add more nodes.

Step 1: Install Nginx

On Ubuntu/Debian:

sudo apt update
sudo apt install nginx -y

On CentOS/RHEL/Rocky:

sudo dnf install epel-release -y
sudo dnf install nginx -y
sudo systemctl enable --now nginx

Verify the install:

nginx -v

Step 2: Design the Cache Directory and Zone

Nginx caching relies on a directory on disk plus an in-memory zone that tracks cache keys and metadata. Create the cache directory first:

sudo mkdir -p /var/cache/nginx/cdn_cache
sudo chown -R www-data:www-data /var/cache/nginx/cdn_cache

(On CentOS-based systems, the Nginx user is typically nginx instead of www-data — adjust accordingly.)

Step 3: Configure Nginx as a Caching Reverse Proxy

Open the main config or create a dedicated file under /etc/nginx/conf.d/cdn.conf. First, define the cache zone at the http block level (in nginx.conf or a file included from it):

proxy_cache_path /var/cache/nginx/cdn_cache
    levels=1:2
    keys_zone=cdn_cache_zone:100m
    max_size=10g
    inactive=60m
    use_temp_path=off;

Here’s what each directive does:

Now define the server block that acts as your edge node:

server {
    listen 80;
    listen 443 ssl http2;
    server_name cdn.example.com;

    ssl_certificate     /etc/letsencrypt/live/cdn.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/cdn.example.com/privkey.pem;

    location / {
        proxy_pass https://origin.example.com;
        proxy_set_header Host origin.example.com;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        proxy_cache cdn_cache_zone;
        proxy_cache_valid 200 302 60m;
        proxy_cache_valid 404 1m;
        proxy_cache_key "$scheme$request_method$host$request_uri";
        proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
        proxy_cache_lock on;
        proxy_cache_lock_timeout 10s;

        add_header X-Cache-Status $upstream_cache_status;

        expires 30d;
        add_header Cache-Control "public, max-age=2592000, immutable";

        gzip_static on;
        sendfile on;
        tcp_nopush on;
        tcp_nodelay on;
    }
}

Some notes on the important pieces:

Step 4: Handle Different Content Types Appropriately

Not everything should be cached the same way. Static assets (images, CSS, JS, fonts) can be cached aggressively; dynamic or user-specific content should not. Split this out with location blocks:

location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|css|js|woff2?|ttf)$ {
    proxy_pass https://origin.example.com;
    proxy_cache cdn_cache_zone;
    proxy_cache_valid 200 7d;
    expires 7d;
    add_header Cache-Control "public, max-age=604800, immutable";
    add_header X-Cache-Status $upstream_cache_status;
}

location /api/ {
    proxy_pass https://origin.example.com;
    proxy_cache off;
    proxy_set_header Host $host;
}

This separation is the difference between a CDN that speeds things up and one that accidentally serves stale API responses to users.

Step 5: Enable Compression

Bandwidth savings matter as much as caching does. Enable gzip (and Brotli if your Nginx build supports it):

gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_comp_level 5;
gzip_types text/plain text/css application/json application/javascript
           text/xml application/xml application/xml+rss text/javascript
           image/svg+xml;

Brotli generally compresses better than gzip for text assets, but it requires the ngx_brotli module, which isn’t built into stock Nginx and needs to be compiled in or installed via a package like libnginx-mod-http-brotli on Debian-based systems.

Step 6: Test the Configuration

Always validate before reloading:

sudo nginx -t
sudo systemctl reload nginx

Then test caching behavior directly:

curl -I https://cdn.example.com/images/logo.png

Look for the X-Cache-Status header. The first request should show MISS; subsequent requests within the cache validity window should show HIT:

HTTP/2 200
x-cache-status: HIT
cache-control: public, max-age=604800, immutable

You can also inspect the cache directory directly to confirm files are being written:

sudo find /var/cache/nginx/cdn_cache -type f | head

Step 7: Purge and Invalidate Cache When Content Changes

Unlike a simple reverse proxy, a CDN needs a way to invalidate stale content. Nginx open-source doesn’t ship a built-in purge command, but there are a few practical approaches:

Option A — Cache key versioning. Append a version or hash to your asset URLs (logo.png?v=3 or logo.abc123.png) so new content gets a new cache key automatically. This is the approach most static site build tools already use and is the most reliable.

Option B — Manual purge by deleting cache files. You can locate and delete cached files matching a URI:

sudo find /var/cache/nginx/cdn_cache -type f -exec grep -l "logo.png" {} \; -delete

This is crude and not recommended for frequent invalidation, but works in a pinch.

Option C — ngx_cache_purge module. This third-party module (available as a package on many distros, or compiled from source) adds a proper PURGE HTTP method:

location ~ /purge(/.*) {
    allow 127.0.0.1;
    deny all;
    proxy_cache_purge cdn_cache_zone "$scheme$request_method$host$1";
}

Then purge with:

curl -X PURGE https://cdn.example.com/purge/images/logo.png

Troubleshooting Common Issues

Cache never shows HIT. Check that proxy_cache_key is consistent and that you’re not accidentally sending Set-Cookie headers from the origin — by default, Nginx won’t cache responses with cookies unless you explicitly tell it to with proxy_ignore_headers Set-Cookie; and proxy_hide_header Set-Cookie;.

Disk fills up unexpectedly. Lower max_size in proxy_cache_path, or shorten inactive. Monitor with du -sh /var/cache/nginx/cdn_cache.

Origin overloaded despite caching. Check proxy_cache_lock is enabled, and verify proxy_cache_valid isn’t set too short for your traffic pattern.

SSL handshake errors between edge and origin. If your origin uses a self-signed cert internally, add proxy_ssl_verify off; (only within a trusted private network — never for public-facing origins).

Security Considerations

Performance Tips

open_file_cache max=10000 inactive=5m;
open_file_cache_valid 2m;
open_file_cache_min_uses 1;
open_file_cache_errors on;

Real-World Use Cases

Best Practices

Wrapping Up

Building a CDN with Nginx won’t replace a global network like Cloudflare for massive scale, but for small-to-medium projects, internal company tools, or cost-conscious teams that want control over their caching logic, it’s a genuinely solid option. The core mechanics — proxy_cache_path, sane cache keys, stale-content fallback, and compression — get you 90% of the practical benefit, and the rest is just adding more edge nodes and pointing DNS at them intelligently. Start with one edge node, verify your hit ratios and header behavior are correct, then scale out geographically once you trust the configuration.

Exit mobile version