Static + CDN cheatsheet.

Static site config

server {
    listen 80;
    server_name static.example.com;
    root /var/www/static;
    index index.html;
    
    location / {
        try_files $uri $uri/ /index.html;     # SPA fallback
    }
    
    location ~* \.(css|js|woff2|woff|svg|webp|jpg|jpeg|png|gif)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }
    
    location = /service-worker.js {
        expires -1;
        add_header Cache-Control "no-cache";
    }
    
    location = /robots.txt { log_not_found off; access_log off; }
    location = /favicon.ico { log_not_found off; access_log off; }
}

Hashed asset filenames

If your build emits app.abc123.js:

location ~* \.[a-f0-9]{6,}\.(js|css|woff2|png|webp)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

Safe to cache forever; content hash invalidates URL.

index.html — short cache

location = /index.html {
    expires -1;
    add_header Cache-Control "no-cache, must-revalidate";
}

Always-fresh entrypoint, immutable assets.

SPA fallback

location / {
    try_files $uri /index.html;
}

For React/Vue/SPA where client routing handles paths.

Compression

gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
    text/plain
    text/css
    text/xml
    application/javascript
    application/json
    application/xml
    application/rss+xml
    image/svg+xml
    application/wasm
    application/manifest+json;
gzip_min_length 256;

Brotli

brotli on;
brotli_comp_level 6;
brotli_types
    text/plain text/css application/javascript application/json image/svg+xml;

Requires ngx_brotli module.

Pre-compressed files

Build outputs both .gz and .br:

gzip_static on;
brotli_static on;

Serves app.js.gz if client supports it, no on-the-fly compression cost.

sendfile

sendfile on;
tcp_nopush on;
sendfile_max_chunk 1m;

Zero-copy file transfers.

open_file_cache

open_file_cache max=10000 inactive=60s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;

Big speedup for many small files.

CORS for fonts

location ~* \.(woff|woff2|ttf|otf|eot)$ {
    add_header Access-Control-Allow-Origin "*" always;
    expires 1y;
}

CDN headers

If behind Cloudflare / CloudFront / Fastly:

real_ip_header CF-Connecting-IP;            # Cloudflare
real_ip_header X-Forwarded-For;
set_real_ip_from 173.245.48.0/20;           # CF range
# ... add all CF ranges
real_ip_recursive on;

ETag / Last-Modified

Default on. Allows 304 responses.

etag on;
if_modified_since exact;

Server timing

add_header Server-Timing "nginx;dur=$request_time" always;

Service Worker (PWA)

location = /sw.js {
    add_header Cache-Control "public, max-age=0, must-revalidate";
    add_header Service-Worker-Allowed "/";
    expires off;
}

Manifest

location ~ \.webmanifest$ {
    types { application/manifest+json webmanifest; }
    add_header Cache-Control "public, max-age=3600";
}

WebP serving

map $http_accept $webp_suffix {
    default "";
    ~*image/webp ".webp";
}

location ~* \.(jpg|png)$ {
    try_files $uri$webp_suffix $uri =404;
    add_header Vary Accept;
}

If foo.jpg.webp exists and browser supports webp, serve it.

CDN as origin

When using a CDN, nginx is the origin. Tighten:

# Only allow CDN IPs to origin
allow 173.245.48.0/20;     # Cloudflare
allow 103.21.244.0/22;
deny all;

CDN cache hints

add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
add_header CDN-Cache-Control "max-age=31536000";   # Cloudflare-specific
add_header Surrogate-Control "max-age=31536000";   # Fastly

Range requests (video / large files)

location ~* \.(mp4|webm)$ {
    mp4;                                  # nginx-mp4 module
}

Or use aio threads for big files:

aio threads;
sendfile off;

Image hot resize (avoid)

Image transformation belongs in a service (imgproxy, Imagen, Cloudinary). Don’t ImageMagick in nginx for prod.

Common mistakes

  • Cache forever for index.html → stale frontend.
  • Missing expires for assets → no client cache.
  • Compression on already-compressed (jpg/png) → CPU waste.
  • gzip_min_length too low → compress tiny responses (negative).
  • Forgetting Vary: Accept-Encoding → CDN caches wrong variant.

Read this next

If you want my static site templates, they’re at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .