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:
- An origin server where your actual content lives (your application server, object storage, or a static file host).
- One or more edge servers, each running Nginx configured as a reverse proxy with caching enabled, placed in different geographic regions.
- 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:
- Nginx 1.18 or newer (1.22+ recommended for improved HTTP/2 and cache handling). Anything with
proxy_cache_pathsupport works, which has been standard for years. - Root or sudo access on each edge server.
- At least one origin server serving the actual files, reachable over HTTP/HTTPS from your edge nodes.
- Enough disk space on each edge node for your cache — SSDs strongly preferred, since cache lookups are I/O heavy under load.
- A domain name with DNS you can configure (ideally supporting geo-routing or at least multiple A/AAAA records).
- Basic familiarity with Linux server administration and firewall rules.
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:
levels=1:2— creates a two-level subdirectory hash structure so you don’t end up with millions of files in one flat directory.keys_zone=cdn_cache_zone:100m— allocates 100MB of shared memory for cache keys and metadata (roughly enough for 800,000 keys).max_size=10g— caps the cache at 10GB on disk; Nginx evicts least-recently-used entries once this is hit.inactive=60m— items not accessed for 60 minutes are removed even if they haven’t expired.use_temp_path=off— writes temp files directly into the cache directory instead of a separate temp path, avoiding an extra copy operation.
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:
proxy_cache_use_stalelets Nginx keep serving cached content even if the origin is briefly down or slow — a real CDN behavior.proxy_cache_lockprevents a “thundering herd,” where dozens of simultaneous requests for an uncached file all hit the origin at once. Only one request goes to the origin; the rest wait for the cache to populate.add_header X-Cache-Status $upstream_cache_statusis invaluable for debugging — it tells you whether a response wasHIT,MISS,BYPASS,EXPIRED, orSTALE.expiresandCache-Controlheaders instruct browsers to cache content too, reducing requests to your edge node in the first place.
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
- Restrict origin access so it only accepts traffic from your edge server IPs, using firewall rules or an origin-only secret header validated by the origin app.
- Enable rate limiting on edge nodes using
limit_req_zoneto prevent abuse from becoming an origin-hammering problem. - Keep TLS termination at the edge with modern ciphers only; disable TLS 1.0/1.1.
- Sanitize and validate the
Hostheader if multiple domains share the same edge fleet, to avoid cache poisoning via mismatched vhosts. - Consider signed URLs or token-based validation at the origin if content shouldn’t be publicly cacheable by anyone who guesses a URL.
Performance Tips
- Increase
worker_connectionsandworker_processes auto;in the main config for edge nodes under heavy load. - Use
open_file_cacheto reduce filesystem overhead for frequently accessed cached files:
open_file_cache max=10000 inactive=5m;
open_file_cache_valid 2m;
open_file_cache_min_uses 1;
open_file_cache_errors on;
- Place cache directories on NVMe/SSD storage; cache performance is often disk-bound before it’s CPU-bound.
- Monitor cache hit ratio regularly — a healthy CDN edge should sit above 85-90% hit rate for static assets. A low ratio usually means cache keys are too granular or
inactiveis too short.
Real-World Use Cases
- A media publisher serving article images to a global audience deploys three Nginx edge nodes (US, EU, APAC) in front of an S3-compatible origin, cutting average image load time by more than half for non-US visitors.
- A SaaS company serves its frontend JS/CSS bundles through an Nginx CDN layer while keeping API traffic uncached and routed directly to application servers.
- An open-source project mirrors release binaries across regional Nginx nodes so downloads don’t bottleneck on a single origin during a release spike.
Best Practices
- Always separate cacheable and non-cacheable routes explicitly — never rely on defaults.
- Version your static assets instead of relying on manual purges.
- Monitor
$upstream_cache_statusin your access logs, not just spot-checked withcurl. - Automate deployment of edge configs with configuration management (Ansible, Salt) so all nodes stay in sync.
- Pair this setup with a real DNS geo-routing layer — without it, you have caching servers, not a true CDN.
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.