Out of the box, a clean WordPress install on Nginx sends terrible cache headers. Browsers end up revalidating static files on every single page view, and edge CDNs drop your cache hit ratio down to an embarrassing 20% or 30%. I’ve watched unoptimized origin servers melt under modest traffic spikes simply because Nginx was telling upstream edge nodes to treat static images like dynamic PHP scripts. Here’s how to write clean Nginx map rules, set precise Cache-Control headers for assets and HTML, and tune CDN caching without breaking logged-in sessions or shopping carts.

Why WordPress Default Headers Kill Cache Hit Ratios
By default, Nginx doesn’t attach long-lived expiration headers to static files unless you explicitly tell it to. When a browser requests wp-content/uploads/photo.jpg, Nginx serves the file with a standard 200 OK, a Last-Modified timestamp, and an ETag. On the next visit, the browser sends a conditional request back to the server, eating 120ms to 350ms of round-trip latency just to receive a 304 Not Modified.
It gets worse when messy plugin rewrite rules leak cookies. If WordPress sets wordpress_logged_in_* or comment_author_* cookies alongside static assets, and your origin returns a Set-Cookie header on an image or script, most edge CDNs immediately mark that file as uncacheable. The edge bypasses its cache, slams your origin server, and runs your VPS memory straight into the wall.
We need a clear split: year-long immutable headers for versioned static assets, moderate caching for general media, short-lived microcaching for anonymous HTML, and zero caching for the admin dashboard and checkout flows.
Setting Up Nginx Maps for Dynamic Cache-Control
Hardcoding headers inside a dozen different location blocks gets messy fast and leads to configuration drift. Instead, define an Nginx map directive inside your global /etc/nginx/nginx.conf file (within the http {} context). This matches file extensions to their target Cache-Control header values in one central place.
Open your main Nginx configuration:
sudo nano /etc/nginx/conf.d/cache-map.confAdd this map block. It inspects the URI extension and assigns an expiration policy to the $cache_control_header variable:
map $request_uri $cache_control_header { default "no-cache, no-store, must-revalidate"; ~*.(?:css|js|woff2?|ttf|eot)$ "public, max-age=31536000, immutable"; ~*.(?:jpg|jpeg|gif|png|webp|avif|ico|svg)$ "public, max-age=2592000, stale-while-revalidate=86400"; ~*.(?:pdf|zip|mp4|webm)$ "public, max-age=604800";
}This gives CSS, JS, and web fonts a one-year TTL (31,536,000 seconds) with the immutable flag, telling modern browsers never to revalidate unless the file URL itself changes. Images get 30 days plus a one-day grace window for stale revalidation. If you want background revalidation paired with your CDN, check out our guide to configure stale-while-revalidate and stale-if-error on Nginx behind a CDN so your origin stays quiet while assets refresh.
Configuring Static Asset Expirations in Your Server Block
Next, open your site’s virtual host file, usually under /etc/nginx/sites-available/yourdomain.com. We need dedicated location blocks that catch static requests, apply our mapped headers, and turn off access logging to save disk I/O.
Drop these rules inside your primary server { ... } block:
# Global media and static assets
location ~* .(css|js|jpg|jpeg|gif|png|webp|avif|ico|svg|woff2?|ttf|eot)$ { expires max; add_header Cache-Control $cache_control_header always; add_header Access-Control-Allow-Origin "*" always; add_header X-Content-Type-Options "nosniff" always; # Strip cookies so CDN edge nodes can cache these assets proxy_hide_header Set-Cookie; fastcgi_hide_header Set-Cookie; log_not_found off; access_log off; try_files $uri =404;
}Pay attention to the add_header Access-Control-Allow-Origin "*" directive. If your CDN serves web fonts (WOFF2) from a subdomain like cdn.yourdomain.com, browsers will outright block them without this CORS header. Read our guide on how to fix CDN font CORS errors and set edge cache rules if you run into cross-origin warnings in the browser console.
Stripping Cookies for Static Assets to Enable CDN Edge Caching
The number one reason CDNs like Cloudflare, Fastly, or CloudFront fail to cache static WordPress files is the presence of Set-Cookie headers on assets. This happens constantly when third-party plugins trigger PHP sessions on missing files or route assets through dynamic handlers.
You can check if your origin is leaking cookies on static files with curl:
curl -I https://yourdomain.com/wp-content/themes/your-theme/style.cssIf you see a Set-Cookie: line in the output, your CDN considers that response private by default and will forward every subsequent request straight to your origin server.
To fix this, explicitly ignore and strip cookies inside any location block serving files from the /wp-content/ directory:
location /wp-content/ { location ~* .(?:jpg|jpeg|gif|png|webp|ico|svg|css|js|woff2?)$ { expires 30d; add_header Cache-Control "public, max-age=2592000, immutable"; # Prevent PHP or upstream modules from setting cookies on assets fastcgi_hide_header Set-Cookie; proxy_hide_header Set-Cookie; access_log off; log_not_found off; }
}Once the edge node sees a response without a Set-Cookie header and with public, max-age=2592000, it safely caches the file on its edge storage.
Handling HTML Microcaching and FastCGI Page Caching
Caching static files is straightforward; caching HTML without breaking WordPress is where things get tricky. You can’t slap a 30-day cache header on dynamic PHP output without breaking user comments, WooCommerce carts, and the wp-admin bar. If an authenticated user gets served cached HTML meant for an anonymous visitor, they won’t see their admin bar or account dashboard.
The fix is split-level caching: let external CDN edge nodes hold anonymous pages for hours, but tell local client browsers to revalidate quickly.
First, detect whether the visitor is an anonymous reader or an active user by checking incoming request cookies. Put this block inside your http {} or server block:
# Check for logged-in users, commenters, or WooCommerce sessions
set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1;
}
if ($query_string != "") { set $skip_cache 1;
}
if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in|woocommerce_items_in_cart") { set $skip_cache 1;
}
if ($request_uri ~* "/wp-admin/|/xmlrpc.php|wp-.*.php|/feed/|index.php|sitemap(_index)?.xml") { set $skip_cache 1;
}If you have dedicated endpoints or REST routes that use bearer tokens, check our guide on how to bypass CDN edge cache for authenticated API routes and user cookies so sensitive responses never end up in a shared cache.
Next, configure your FastCGI PHP location block to pass split cache headers based on $skip_cache:
location ~ .php$ { try_files $uri =404; fastcgi_split_path_info ^(.+.php)(/.+)$; fastcgi_pass unix:/run/php/php8.2-fpm.sock; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; # Adjust headers based on session status if ($skip_cache = 1) { add_header Cache-Control "private, no-cache, no-store, must-revalidate" always; } if ($skip_cache = 0) { # 10 minutes in the browser, 4 hours at the CDN edge add_header Cache-Control "public, max-age=600, s-maxage=14400" always; }
}The MDN documentation on Cache-Control directives explains how s-maxage works: browsers look at max-age=600 and only keep the page locally for 10 minutes, but shared edge proxies and CDNs read s-maxage=14400 and serve that cached HTML for 4 hours. That cuts down FastCGI PHP worker load significantly.
If you’re also running Redis behind PHP, you can pair this with object caching. See our tutorial to configure Redis object cache for WordPress on a VPS to take pressure off your MySQL database.
Passing the Correct Headers to Your CDN Edge
When your site sits behind a CDN reverse proxy, you need Nginx to respect forwarded headers and manage cache keys properly.
Add these parameters to your main server configuration:
# Ensure Nginx reports the correct schemes behind an SSL CDN
fastcgi_param HTTPS on;
fastcgi_param HTTP_SCHEME https; # Inform the CDN that responses vary by encoding
add_header Vary "Accept-Encoding, Cookie" always;The Vary: Accept-Encoding, Cookie header prevents the edge from serving Gzip-compressed files to clients that requested Brotli, or serving an anonymous cached page to an authenticated user. For edge-specific tweaks, you can also read how to override origin Cache-Control headers with CDN edge rules when you need quick adjustments without redeploying Nginx configs.
Always verify your Nginx syntax before reloading:
sudo nginx -tIf it returns syntax is ok and test is successful, reload the service:
sudo systemctl reload nginxVerifying Cache Status with cURL and Chrome DevTools
Don’t just assume your headers are working; verify them with curl -I to inspect raw response headers directly.
First, test a static CSS file:
curl -I https://yourdomain.com/wp-includes/css/dist/block-library/style.min.cssYou should see our mapped cache header applied:
HTTP/2 200
server: nginx
date: Wed, 24 Oct 2024 14:15:20 GMT
content-type: text/css; charset=utf-8
content-length: 12480
last-modified: Tue, 15 Oct 2024 08:00:00 GMT
etag: "670e2480-30c0"
cache-control: public, max-age=31536000, immutable
access-control-allow-origin: *
x-content-type-options: nosniffNext, test an anonymous request to your homepage to confirm s-maxage is present:
curl -I https://yourdomain.com/Look for this line in the response:
cache-control: public, max-age=600, s-maxage=14400
vary: Accept-Encoding, CookieFinally, simulate an authenticated user by passing a WordPress login cookie:
curl -I -H "Cookie: wordpress_logged_in_fakecookiehash=1" https://yourdomain.com/The headers should immediately flip to private non-cacheable mode:
cache-control: private, no-cache, no-store, must-revalidateThis confirms logged-in users bypass the cache completely while public visitors get instant, edge-cached HTML.
Frequently Asked Questions
What is the difference between max-age and s-maxage?
The max-age directive applies to private browser caches on the user’s local device. The s-maxage directive applies only to public, shared caches like CDNs and reverse proxies. By splitting them, you can let your CDN edge hold a page for 4 hours while forcing the user’s browser to check back in 10 minutes.
Why does my CDN return HIT for HTML even when I edit a post?
Because the CDN honors the s-maxage timer until it expires or until an invalidation event occurs. Install a cache-clearing plugin connected to your CDN’s API (like Cloudflare or Fastly) so edge caches get purged automatically when you publish or update a post. Alternatively, drop s-maxage down to 300 seconds during active editing periods.
Will immutable break CSS or JS files when I update plugins?
No, as long as your themes and plugins use standard WordPress enqueueing functions like wp_enqueue_style() and wp_enqueue_script(). WordPress appends a version query string (like ?ver=6.5.2) to asset URLs. When an update drops, that version string changes, and browsers treat it as an entirely new file.
How do I test origin headers without going through the CDN?
You can bypass the CDN completely by querying your origin server’s direct IP address while manually passing the Host header:
curl -I -H "Host: yourdomain.com" http://YOUR_SERVER_IP/wp-includes/css/dist/block-library/style.min.cssThis shows the raw headers Nginx generates before your CDN modifies, caches, or strips anything.
What to Build Next
Now that your cache headers and edge rules are running smoothly, secure the origin so bots can’t bypass your CDN and hit your Nginx port directly. Read our tutorial on how to restrict origin server traffic to CDN shield IPs with UFW and Nginx to lock down your server.

