How To Configure NGINX as CDN

Written by: Bagus Facsi Aginsa
Published at: 02 Dec 2023


A CDN is, at its core, a cache server placed close to your users: it keeps copies of your content so requests are answered locally instead of traveling all the way to your origin. Commercial CDNs sell exactly this. But NGINX has the same caching capability built in through ngx_http_proxy_module, so if you have users far from your origin server (or a streaming origin you want to protect from load), you can put an NGINX cache node in front of it and run your own CDN.

In this tutorial you will configure NGINX as a CDN edge cache: set up proxy_cache with sensible defaults for large static files and video, verify cache HITs and MISSes with real requests, and learn how to purge content and grow the setup into a multi-tier CDN.


How NGINX Caching Works

When a request arrives, NGINX computes a cache key (by default derived from the URL), hashes it with MD5, and looks for a matching file in its cache directory:

  • MISS: no cached copy exists. NGINX forwards the request to the origin (proxy_pass), streams the response to the client, and stores a copy on disk.
  • HIT: a valid cached copy exists. NGINX serves it straight from local disk. The origin never sees the request.
  • EXPIRED / REVALIDATED: the copy exists but its validity time has passed; NGINX checks with the origin whether the file changed before reusing it.

Everything else in the configuration below is about controlling these transitions: how long a copy stays valid, what happens while it is being refreshed, and how many concurrent requests are allowed to hit the origin for the same file.


Prerequisites

  • A server running Ubuntu 22.04 or 24.04 LTS, ideally located near the users you want to serve
  • NGINX installed. The standard package is enough, proxy_cache is a core module: sudo apt install nginx. (If you built NGINX from source for streaming, that works too: How to Install Nginx from Source)
  • An origin server with the content to cache: a web server, object storage, or a streaming origin such as the one from How to Build an Adaptive Bitrate VoD Server with Nginx VOD Module
  • A user with sudo privileges
  • Enough disk on the cache node. The cache can grow to whatever max_size you allow it

Step 1: Create the Cache Configuration

Back up the current configuration:

sudo cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.backup

Open it:

sudo nano /etc/nginx/nginx.conf

Replace the contents with this configuration:

user www-data;
worker_processes auto;
worker_rlimit_nofile 4096;
pid /run/nginx.pid;

events {
    worker_connections 4096;
}

http {
    # Basic Settings
    include mime.types;
    default_type application/octet-stream;
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    server_tokens off;
    keepalive_timeout 65;

    # Log Settings: includes upstream + cache status per request
    log_format cdn_log '$remote_addr - $remote_user [$time_local] "$request" '
                       '$status $body_bytes_sent "$http_referer" '
                       '"$http_user_agent" "$http_x_forwarded_for" '
                       'rt=$request_time '
                       'ua="$upstream_addr" us="$upstream_status" '
                       'ut="$upstream_response_time" ul="$upstream_response_length" '
                       'cs=$upstream_cache_status';
    access_log /var/log/nginx/access.log cdn_log;

    # Core Cache Setting
    proxy_cache_key $uri;
    proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=video_cache:10m
                     max_size=10g inactive=30d use_temp_path=off;

    server {
        listen 80; # or 443 with ssl directives if you terminate SSL here
        server_name your.domain.com;

        location / {
            # activate the cache zone defined above
            proxy_cache video_cache;

            # origin resends file only if it changed
            proxy_cache_revalidate on;

            # serve stale cache while refreshing in the background
            proxy_cache_background_update on;
            proxy_cache_use_stale updating error timeout;

            # only 1 request per file reaches the origin at a time
            proxy_cache_lock on;

            # how long a successful response stays valid
            proxy_cache_valid 200 7d;

            # cache decisions are ours, not the origin's headers
            proxy_ignore_headers Cache-Control;
            proxy_ignore_headers Set-Cookie;
            proxy_ignore_headers Expires;

            # expose cache status to clients and logs
            add_header X-Cache-Status $upstream_cache_status;

            # upstream connection settings
            proxy_http_version 1.1;
            proxy_set_header Connection "";
            proxy_set_header Host $host;
            proxy_pass http://origin.server.com;
        }
    }
}

Create the cache directory and give it to the NGINX user:

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

Step 2: Understand the Cache Directives

server_name is the domain your CDN node serves. Point a DNS record (like cdn.your.domain.com) at this machine.

proxy_cache_key $uri defines the cache file key, or simply what makes two requests “the same content”. The cache file name is the MD5 hash of this key: a request for /images/my-sweet-cat.jpg becomes a file named c223cd4fbcf6ae9c78ab4f6dabd0beab. Using $uri (path only) means query strings are ignored. That is right for video segments and static assets, but wrong if ?size=small returns different content (use $uri$is_args$args in that case).

proxy_cache_path sets where cache files live and the zone parameters. levels=1:2 spreads files across subdirectories (/var/cache/nginx/b/ea/c223cd4f...) so no single directory holds millions of entries. keys_zone=video_cache:10m allocates shared memory for keys and metadata; 1 MB stores about 8,000 keys. max_size=10g caps disk usage; when full, NGINX evicts the least recently used files. inactive=30d removes any file not accessed for 30 days, regardless of validity. use_temp_path=off writes cache files in place instead of copying them from a temp directory. Always set this.

proxy_cache video_cache activates caching in this location, referencing the keys_zone name.

proxy_cache_revalidate on: when a cached item expires, NGINX asks the origin with If-Modified-Since/If-None-Match. If the file is unchanged, the origin answers 304 with no body, and the CDN just extends the existing copy without re-downloading it.

proxy_cache_background_update and proxy_cache_use_stale: when an item is being refreshed (or the origin errors out), clients receive the stale cached copy immediately instead of waiting. This is what keeps a CDN fast and keeps it serving during origin outages.

proxy_cache_lock on: when a popular new file gets 500 simultaneous requests, only the first goes to the origin; the rest wait for the cache to populate. Without this, a cache miss on a viral file becomes a thundering herd against your origin.

proxy_cache_valid 200 7d: successful (200) responses are treated as valid for 7 days. Add lines like proxy_cache_valid 404 1m; if you also want to briefly cache negatives.

proxy_ignore_headers ... makes cache lifetime decisions on the CDN, ignoring the origin’s Cache-Control, Expires, and Set-Cookie headers. (A Set-Cookie on a response makes NGINX refuse to cache it, which surprises many people.)

add_header X-Cache-Status $upstream_cache_status stamps every response with HIT, MISS, EXPIRED, REVALIDATED, UPDATING, or STALE. This is your main observability tool, and the log format above records the same value as cs=.

proxy_set_header Connection "" with proxy_http_version 1.1 enables keepalive-capable HTTP/1.1 connections to the origin instead of closing the connection after every fetch.

proxy_pass is your origin server. Domain or IP, whatever the CDN node can reach.


Step 3: Test and Reload

sudo nginx -t
sudo systemctl reload nginx

Step 4: Verify Cache HITs and MISSes

Request the same file twice and watch the X-Cache-Status header. First request:

curl -I http://your.domain.com/images/my-sweet-cat.jpg
HTTP/1.1 200 OK
X-Cache-Status: MISS

MISS means the file came from the origin and is now stored. Second request:

curl -I http://your.domain.com/images/my-sweet-cat.jpg
HTTP/1.1 200 OK
X-Cache-Status: HIT

HIT means it was served from local disk; the origin was not contacted. You can confirm the cache file exists on disk:

sudo ls /var/cache/nginx/*/*/

And watch cache behaviour across real traffic in the access log (the cs= field):

sudo tail -f /var/log/nginx/access.log | grep -o 'cs=[A-Z]*'

A healthy CDN serving stable content should trend toward cs=HIT for the vast majority of lines.


Purging Cached Content

Sooner or later you will publish a broken file and need it out of the cache now. Open-source NGINX has no purge API (that is an NGINX Plus feature), but you have two practical options.

Delete the cache file directly. Compute the MD5 of the cache key (with proxy_cache_key $uri, the key is just the path) and remove the file:

echo -n "/images/my-sweet-cat.jpg" | md5sum
# c223cd4fbcf6ae9c78ab4f6dabd0beab
sudo find /var/cache/nginx -name "c223cd4fbcf6ae9c78ab4f6dabd0beab" -delete

The next request will be a MISS and refetch from the origin.

Nuke everything (small caches, emergencies):

sudo rm -rf /var/cache/nginx/*
sudo systemctl restart nginx

For frequent, automated purging, look at the third-party ngx_cache_purge module, which adds a PURGE request method. It requires compiling NGINX from source.


Common Problems and Troubleshooting

Everything is always MISS.

Check for Set-Cookie from your origin (the proxy_ignore_headers Set-Cookie line handles it), confirm the cache directory is writable by www-data, and look for cache-key mismatches. If your origin URLs carry unique query strings (cache busters, tokens), $uri as key should collapse them; a key that includes $args will never repeat.

X-Cache-Status header is missing entirely.

The response never went through a location with proxy_cache enabled. This is usually a server_name/DNS mismatch, so the request landed on the default server block instead.

Disk fills up beyond max_size.

max_size is enforced by a background process (the cache manager), so short spikes above the limit are normal. If it grows unbounded, check that use_temp_path=off is set and that nothing else writes to the same partition.

Slow first-byte on cache misses for large video files.

By default NGINX buffers the whole upstream response. For large files consider proxy_cache_lock_timeout tuning and slice-based caching (the slice module fetches large files in ranged chunks). This becomes worthwhile once your files exceed a few hundred MB.


Best Practices

Put the cache on its own disk or partition. A full cache partition should never take down the OS or logs. Fast SSD storage directly translates into CDN throughput.

Add HTTPS. Serve your CDN domain with a trusted certificate. Secure Nginx with Let’s Encrypt SSL Using Certbot on Ubuntu covers it end to end.

Monitor your hit ratio. The cs= field in the log format above makes this easy to aggregate. A dropping hit ratio means your cache is too small (max_size), your validity too short (proxy_cache_valid), or your cache key too granular.

Protect the origin, not just the edge. proxy_cache_lock, proxy_cache_use_stale error timeout, and generous proxy_cache_valid all exist so that origin failures and load spikes stay invisible to users.


Conclusion

You have configured NGINX as a CDN cache node: proxy_cache_path defines the store, the location-level directives control validity, revalidation, and origin protection, and the X-Cache-Status header plus the cs= log field prove it is working.

One node is a cache; several make a CDN. Deploy the same configuration in each region, point regional DNS (or GeoDNS) at the nearest node, and have them all pull from one origin. When you grow to multiple tiers (edge nodes pulling from regional caches pulling from the origin), securing the links between tiers matters: Configure Mutual TLS (mTLS) Between Edge and Regional CDN shows the pattern, and Apache Traffic Server as a Caching Reverse Proxy is a purpose-built alternative for the caching tier. For the origin side of a video CDN, see How to Build an Adaptive Bitrate VoD Server with Nginx VOD Module.