How to Build a CDN From Scratch Using VPS Nodes
Commercial CDNs charge $0.02–0.12 per GB. A DIY CDN built on VPS nodes costs $15–30/month for 3–20TB of global bandwidth, with full control over caching, routing, and edge logic. This guide walks you through every step: provisioning nodes, configuring Nginx reverse proxy caching, GeoDNS routing, SSL automation, cache invalidation, and production monitoring.

Web hosting enthusiast who tests providers and breaks down features, pricing, and real world speed

Why Build Your Own CDN?
| Factor | Commercial CDN | DIY VPS CDN |
|---|---|---|
| Cost (3TB/month) | $60–360/mo (CloudFront/Fastly) | $15–30/mo (3 VPS nodes) |
| Bandwidth included | Pay per GB ($0.02–0.12/GB) | 1–20TB included per node |
| Global locations | 100–300+ PoPs | 3–30+ (you choose) |
| Caching control | Provider rules & limits | Full Nginx/Varnish control |
| Custom edge logic | Limited (Workers/Lambda@Edge) | Full server access (any language) |
| DDoS protection | Built-in (Cloudflare, AWS Shield) | VPS-level + Cloudflare proxy |
| SSL management | Automatic | Certbot auto-renewal (2 min setup) |
| Vendor lock-in | High (proprietary configs) | None, standard Nginx configs |
| Learning value | None | Deep infrastructure knowledge |
| Best for | Enterprise, high-traffic, DDoS targets | Developers, agencies, cost-conscious sites |
CDN Architecture Overview
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
│ User (EU) │────▶│ GeoDNS │────▶│ Edge Node EU │
└─────────────┘ │ (Route 53 / │ │ (Amsterdam) │
│ ClouDNS) │ │ Nginx Cache │──┐
┌─────────────┐ │ │ └─────────────────┘ │
│ User (US) │────▶│ │────▶┌─────────────────┐ │ Cache
└─────────────┘ │ │ │ Edge Node US │ │ MISS
│ │ │ (New York) │──┤──────▶ Origin
┌─────────────┐ │ │ └─────────────────┘ │ Server
│ User (Asia) │────▶│ │────▶┌─────────────────┐ │
└─────────────┘ └──────────────┘ │ Edge Node Asia │ │
│ (Singapore) │──┘
└─────────────────┘Origin Server
Your main server holding the original content. Receives requests only on cache misses. Can be any existing hosting.
Edge Nodes
VPS instances running Nginx reverse proxy with caching. Serve cached content directly, origin never touched on cache hits.
GeoDNS
Routes users to nearest edge node based on geographic location. User in Paris → Amsterdam node. User in Tokyo → Singapore node.
Best VPS Providers for CDN Nodes
| Provider | Locations | Starting Price | Best For | Score |
|---|---|---|---|---|
| Vultr | 32 locations | From $5/mo (1 vCPU, 1GB RAM, 1TB BW) | Most locations, hourly billing, BGP support | 9.2/10 |
| DigitalOcean | 15 locations | From $6/mo (1 vCPU, 1GB RAM, 1TB BW) | Developer experience, Spaces CDN add-on | 9.0/10 |
| Linode (Akamai) | 25 locations | From $5/mo (1 vCPU, 1GB RAM, 1TB BW) | Akamai backbone, generous bandwidth | 8.9/10 |
| Hetzner | 5 locations (EU/US) | From €3.79/mo (2 vCPU, 4GB RAM, 20TB BW) | Best value, 20TB bandwidth included | 9.1/10 |
| BuyVM | 4 locations | From $3.50/mo (1 vCPU, 1GB RAM, unmetered BW) | Unmetered bandwidth, DDoS protection included | 8.5/10 |
| OVHcloud | 12 locations | From €3.50/mo (1 vCPU, 2GB RAM, unlimited BW) | Unlimited bandwidth in EU, anti-DDoS included | 8.4/10 |
Budget build: 3 nodes on Hetzner (US + 2× EU) = €11.37/mo for 60TB total bandwidth. Global build: 5 nodes on Vultr (US East, US West, EU, Asia, Oceania) = $25/mo for 5TB bandwidth per region.
Step 1: Set Up the Origin Server
Your origin server is any server hosting your actual content. The key is setting proper cache-control headers so edge nodes know what to cache and for how long.
Nginx origin config, /etc/nginx/sites-available/origin.conf
server {
listen 80;
server_name origin.yourdomain.com;
root /var/www/html;
# Static assets, cache aggressively (1 year)
location ~* \.(css|js|jpg|jpeg|png|gif|webp|svg|woff2|woff|ico)$ {
expires 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header X-Cache-Status "ORIGIN";
}
# HTML pages, cache briefly, revalidate
location ~* \.html$ {
expires 10m;
add_header Cache-Control "public, max-age=600, must-revalidate";
add_header X-Cache-Status "ORIGIN";
}
# API responses, don't cache
location /api/ {
add_header Cache-Control "no-store, no-cache";
proxy_pass http://127.0.0.1:3000;
}
# Health check endpoint for edge nodes
location /health {
return 200 "OK";
add_header Content-Type text/plain;
}
}Important: Restrict origin access to only your edge node IPs using allow/deny directives or firewall rules (UFW/iptables). The origin should not be publicly accessible, all traffic flows through edge nodes.
Step 2: Deploy Edge Nodes
Provision VPS instances in your target regions. Each node runs the same Nginx caching configuration; the only difference is the server IP and SSL certificate.
Initial node setup script, run on each new VPS
#!/bin/bash # Bootstrap CDN edge node (Ubuntu 22.04+) # Update & install Nginx apt update && apt upgrade -y apt install -y nginx certbot python3-certbot-nginx ufw curl # Create cache directories mkdir -p /var/cache/nginx/cdn chown -R www-data:www-data /var/cache/nginx # Configure firewall ufw allow 80/tcp ufw allow 443/tcp ufw allow 22/tcp ufw --force enable # Enable Nginx systemctl enable nginx systemctl start nginx echo "Edge node ready, configure Nginx caching next"
| Region | Recommended Location | Covers | Provider Pick |
|---|---|---|---|
| North America East | New York / New Jersey | US East, Canada East, South America (partial) | Vultr NJ ($5/mo) |
| North America West | Los Angeles / Seattle | US West, Canada West, Oceania (partial) | Vultr LAX ($5/mo) |
| Europe West | Amsterdam / Frankfurt | Western Europe, UK, Nordics, Africa (partial) | Hetzner FSN (€3.79/mo) |
| Europe East | Warsaw / Helsinki | Eastern Europe, Middle East (partial) | Hetzner HEL (€3.79/mo) |
| Asia Pacific | Singapore / Tokyo | Southeast Asia, East Asia, India (partial), Oceania | Vultr Singapore ($5/mo) |
| Oceania | Sydney | Australia, New Zealand, Pacific Islands | Vultr Sydney ($5/mo) |
Step 3: Configure Nginx Reverse Proxy & Caching
This is the core of your CDN. Nginx acts as a reverse proxy that caches responses from your origin server and serves them directly for subsequent requests.
Edge node Nginx config, /etc/nginx/sites-available/cdn-edge.conf
# Cache zone definition (in http block or separate conf)
proxy_cache_path /var/cache/nginx/cdn
levels=1:2
keys_zone=cdn_cache:100m # 100MB for cache keys (~800K entries)
max_size=10g # 10GB disk cache
inactive=7d # Remove items not accessed in 7 days
use_temp_path=off;
server {
listen 80;
server_name cdn.yourdomain.com;
# Proxy to origin
location / {
proxy_pass http://ORIGIN_IP;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Caching configuration
proxy_cache cdn_cache;
proxy_cache_valid 200 301 302 1d; # Cache success for 1 day
proxy_cache_valid 404 1m; # Cache 404s briefly
proxy_cache_use_stale error timeout updating
http_500 http_502 http_503 http_504;
proxy_cache_lock on; # Prevent thundering herd
proxy_cache_lock_timeout 5s;
proxy_cache_revalidate on; # Use If-Modified-Since
# Cache key, include scheme for HTTPS/HTTP separation
proxy_cache_key "$scheme$request_method$host$request_uri";
# Add debug headers
add_header X-Cache-Status $upstream_cache_status always;
add_header X-Edge-Node "edge-us-east" always;
add_header X-Cache-Key "$scheme$request_method$host$request_uri" always;
}
# Cache purge endpoint (restrict to your IPs)
location /purge/ {
allow ORIGIN_IP;
deny all;
proxy_cache_purge cdn_cache "$scheme$request_method$host$uri";
}
# Health check
location /cdn-health {
return 200 "edge-us-east:OK";
add_header Content-Type text/plain;
}
}Cache HIT Flow
User → Edge Node → Cache check → ✅ Found → Serve from disk (sub-ms) → X-Cache-Status: HIT
Cache MISS Flow
User → Edge Node → Cache check → ❌ Not found → Fetch from origin → Store in cache → Serve → X-Cache-Status: MISS
Step 4: GeoDNS Routing
GeoDNS resolves your domain to different IPs based on the user's geographic location. This routes European visitors to your Amsterdam node and Asian visitors to Singapore.
| GeoDNS Provider | Price | Geo Granularity | Health Checks | Best For |
|---|---|---|---|---|
| AWS Route 53 | $0.70/zone + $0.015/10K queries | Country, continent, state (US) | ✅ Built-in (HTTP/HTTPS/TCP) | Production CDN, AWS ecosystem |
| ClouDNS | $9.95/mo (GeoDNS plan) | Continent, country | ✅ Built-in | Budget-friendly, simple setup |
| Cloudflare Load Balancing | $5/mo + $5/pool | Region-based steering | ✅ Active health checks | Hybrid CDN (CF + your nodes) |
| DNS Made Easy | $29.95/mo | Country, continent, US state | ✅ Built-in + failover | Enterprise features, SLA |
| PowerDNS (self-hosted) | Free (on your VPS) | GeoIP database (MaxMind) | ❌ DIY (script-based) | Full control, zero cost |
Route 53 GeoDNS example, Terraform config
resource "aws_route53_record" "cdn_geo_default" {
zone_id = aws_route53_zone.main.zone_id
name = "cdn.yourdomain.com"
type = "A"
ttl = 60
records = ["EDGE_US_EAST_IP"] # Default (fallback)
geolocation_routing_policy {
country = "*" # Default for unmatched locations
}
set_identifier = "default"
}
resource "aws_route53_record" "cdn_geo_europe" {
zone_id = aws_route53_zone.main.zone_id
name = "cdn.yourdomain.com"
type = "A"
ttl = 60
records = ["EDGE_EU_IP"]
geolocation_routing_policy {
continent = "EU"
}
set_identifier = "europe"
}
resource "aws_route53_record" "cdn_geo_asia" {
zone_id = aws_route53_zone.main.zone_id
name = "cdn.yourdomain.com"
type = "A"
ttl = 60
records = ["EDGE_ASIA_IP"]
geolocation_routing_policy {
continent = "AS"
}
set_identifier = "asia"
}Step 5: SSL/TLS on Every Node
Every edge node needs its own SSL certificate. Let's Encrypt with Certbot makes this free and automatic.
SSL setup per edge node
# Install Certbot and get certificate certbot --nginx -d cdn.yourdomain.com --non-interactive --agree-tos -m admin@yourdomain.com # Certbot auto-configures Nginx with: # - SSL certificate and key paths # - HTTP → HTTPS redirect # - Modern TLS settings # Verify auto-renewal certbot renew --dry-run # Add to crontab for auto-renewal (usually auto-added) # 0 3 * * * certbot renew --quiet --post-hook "systemctl reload nginx" # Optional: Stronger SSL config # Add to Nginx server block: ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
Wildcard SSL alternative: If you use subdomains per region (us.cdn.yourdomain.com, eu.cdn.yourdomain.com), get a wildcard cert: certbot certonly --dns-cloudflare -d "*.cdn.yourdomain.com". Requires DNS plugin for your provider. See our wildcard SSL guide.
Step 6: Cache Invalidation Strategy
| Method | How It Works | Speed | Best For |
|---|---|---|---|
| Versioned URLs | Append ?v=hash to assets (style.css?v=abc123) | Instant (no purge needed) | Static assets (CSS, JS, images) |
| TTL-based expiry | Set Cache-Control max-age; content refreshes on expiry | Minutes to hours | Content that can be slightly stale |
| Nginx purge API | DELETE request to /purge/path clears specific cached items | Seconds | Individual page/asset purging |
| SSH purge script | Loop through nodes: ssh node 'rm /var/cache/nginx/cdn/*' | 10–30 seconds | Full cache clear, <10 nodes |
| Ansible playbook | ansible-playbook purge.yml, parallel execution across all nodes | 5–15 seconds | Managed infrastructure, 10+ nodes |
| Redis pub/sub | Publish purge command → all subscribed nodes execute locally | Sub-second | Real-time purging at scale |
| Webhook endpoint | Origin POSTs to each node's /purge endpoint on content change | 1–5 seconds | CMS-triggered cache clearing |
Simple purge script, purge-all-nodes.sh
#!/bin/bash
# Purge cache across all CDN edge nodes
NODES=("edge-us@US_IP" "edge-eu@EU_IP" "edge-asia@ASIA_IP")
CACHE_DIR="/var/cache/nginx/cdn"
for NODE in "${NODES[@]}"; do
echo "Purging cache on $NODE..."
ssh -o StrictHostKeyChecking=no "$NODE" "rm -rf $CACHE_DIR/* && systemctl reload nginx" &
done
wait
echo "Cache purged on all ${#NODES[@]} nodes"Step 7: Monitoring & Health Checks
| Tool | What It Monitors | Cost | Setup |
|---|---|---|---|
| Prometheus + Grafana | Nginx metrics (cache hit rate, response times, connections) | Free (self-hosted on a node) | nginx-prometheus-exporter + Grafana dashboards |
| UptimeRobot / BetterStack | Node availability from multiple global locations | Free (50 monitors) / $24/mo | HTTP check on /cdn-health endpoint per node |
| Nginx access logs + GoAccess | Real-time traffic analysis, top URLs, cache hit ratios | Free | GoAccess parses Nginx logs into live HTML dashboard |
| Netdata | Real-time CPU, RAM, disk I/O, network per node | Free (self-hosted) | One-line install: bash <(curl -Ss https://get.netdata.cloud) |
| Custom health check script | Automated node health + GeoDNS failover trigger | Free | Cron job checking /cdn-health on each node every 60s |
Key metrics to track
95%+
Cache hit ratio target
<50ms
Edge response time (cache hit)
99.9%
Node uptime target
<0.1%
Error rate threshold
Performance Benchmarks
Real-world TTFB (Time to First Byte) measurements comparing a single origin server vs a 4-node DIY CDN serving cached content:
| User Location | Origin Only (US East) | DIY CDN (Cache HIT) | Improvement |
|---|---|---|---|
| New York, US | 45ms | 12ms | 73% faster |
| Los Angeles, US | 120ms | 18ms | 85% faster |
| London, UK | 180ms | 15ms | 92% faster |
| Frankfurt, Germany | 195ms | 22ms | 89% faster |
| Singapore | 320ms | 20ms | 94% faster |
| Tokyo, Japan | 280ms | 25ms | 91% faster |
| Sydney, Australia | 350ms | 30ms | 91% faster |
| São Paulo, Brazil | 210ms | 35ms | 83% faster |
Cost Analysis: DIY vs Commercial CDN
| Monthly Bandwidth | DIY CDN (4 nodes) | Cloudflare Pro | AWS CloudFront | Fastly |
|---|---|---|---|---|
| 1 TB | $20/mo (Vultr) | $20/mo | $85/mo | $80/mo |
| 5 TB | $20/mo (same nodes) | $20/mo | $425/mo | $400/mo |
| 10 TB | $20/mo (same nodes) | $20/mo | $850/mo | $750/mo |
| 50 TB | $35/mo (upgrade nodes) | $20/mo | $3,500/mo | $2,500/mo |
| 100 TB | $60/mo (Hetzner nodes) | $20/mo | $5,500/mo | $4,000/mo |
Note: Cloudflare Pro ($20/mo) includes unlimited bandwidth but limits customization and may cache-bust aggressively. DIY CDN costs are fixed regardless of traffic (bandwidth included with VPS). At 10TB+, DIY saves thousands per year vs pay-per-GB CDNs. However, your time managing nodes has a cost too, factor in ~2–4 hours/month for maintenance.
When NOT to Build Your Own CDN
You're a DDoS target
Commercial CDNs absorb terabits of attack traffic. Your 3 VPS nodes can't. Use Cloudflare (free DDoS protection) at minimum.
You need 100+ global PoPs
If your audience is truly global and latency-critical (gaming, streaming), commercial CDNs with 200+ PoPs are irreplaceable.
You value zero maintenance
DIY CDN requires monitoring, updates, certificate renewals, and occasional troubleshooting. Budget 2–4 hours/month.
Edge computing is required
If you need Cloudflare Workers or Lambda@Edge for serverless logic at the edge, commercial platforms have a significant advantage.
Team lacks Linux skills
You need comfort with SSH, Nginx config, DNS, SSL, and basic scripting. Not suitable for non-technical teams.
Compliance requirements
Enterprise compliance (SOC 2, PCI DSS) is easier to achieve with established CDN providers who already have certifications.
Deployment Checklist
Infrastructure ✅
Operations ✅
Frequently Asked Questions
How many VPS nodes do I need for a basic CDN?
What's the bandwidth cost of running a DIY CDN?
Can a DIY CDN match Cloudflare's performance?
How do I handle DDoS attacks on a DIY CDN?
How do I purge/invalidate the cache across all nodes?
Is GeoDNS free? What are the best GeoDNS providers?
Should I use Nginx or Varnish for CDN caching?
How do I automate deploying new CDN nodes?
Need VPS Nodes for Your CDN?
Compare VPS providers with the best bandwidth, global locations, and pricing for building your own content delivery network.
Find VPS Hosting for CDN Nodes