Configure Cache-Control Headers in WordPress .htaccess for CDN Caching

by Fahim

A CDN is only as smart as the headers your origin spits back. When an edge POP pulls a stylesheet or hero image from WordPress, it checks Cache-Control to decide whether to cache that asset across its network or hammer your origin on every subsequent hit.

Out of the box, stock Apache configs and WordPress either return generic headers or skip them entirely. We can fix that by tuning mod_headers and mod_expires in your WordPress .htaccess so your edge CDN actually does its job.

Terminal screen showing HTTP Cache-Control response headers in curl
Terminal screen showing HTTP Cache-Control response headers in curl

Why CDNs Need Explicit Origin Headers

Your CDN acts as a reverse proxy. When someone hits /wp-content/uploads/logo.png, the edge POP checks its local cache. If the asset isn’t there or expired, it fetches the file from your origin server.

Without explicit caching headers, edge servers guess using heuristic freshness rules. In practice, most CDNs play it safe and fall back to Cache-Control: public, max-age=0, must-revalidate, which turns your expensive CDN into an unnecessary forwarder that hits your server anyway. You can spot this directly in your network tab, much like we do when we inspect ThemeForest theme demos in Chrome DevTools to audit asset sizes and headers.

To keep CDN hit ratios high, your origin must tell edge proxies exactly who can store the resource, how long to hold onto it, and when revalidation is needed.

Understanding Cache-Control Directives for Edge CDNs

The HTTP spec gives us a handful of directives to control edge caches and client browsers. MDN has the full breakdown in their MDN Web Docs on Cache-Control, but here are the ones you actually need for a CDN setup:

  • public: Allows any intermediate cache—browsers, corporate proxies, and edge CDNs—to store the response, even if the request carried an Authorization header.
  • private: Restricts caching to the end-user’s browser. CDNs will drop this from edge storage completely.
  • max-age=: How long (in seconds) the asset is fresh for both browsers and intermediate caches.
  • s-maxage=: Stands for shared max-age. It overrides max-age exclusively for public shared caches (like your CDN). Browsers ignore it, which lets you cache assets longer at the edge than in user browsers.
  • immutable: Tells caches the file body will never change during its freshness lifetime, skipping 304 revalidation requests altogether.
  • no-transform: Prevents CDNs and mobile proxies from converting images or re-minifying scripts on the fly if you handle optimization at the origin.

Pairing s-maxage with max-age gives you a great escape hatch: you can tell browsers to hold CSS for 7 days while letting edge nodes cache it for 30 days.

Configuring mod_headers in WordPress .htaccess

Apache handles caching headers through two modules: mod_headers and mod_expires. While mod_expires sets legacy Expires timestamps and basic max-age directives, mod_headers gives you full control over modern directives like s-maxage and immutable.

Open your root .htaccess via SSH or SFTP. Always place your custom rules above the default # BEGIN WordPress block; otherwise, core rewrite logic can intercept requests before headers get appended. If you run subdomains on the same host, watch out for parent directory overrides—take a look at how to fix root .htaccess inheritance on subdomains so parent rules don’t clobber your child sites.

Drop this configuration near the top of your .htaccess:

 # Static media assets: cache for 1 year at both edge and browser  Header set Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable"  # Web fonts: cache for 1 year with CORS access  Header set Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable" Header set Access-Control-Allow-Origin "*"  # Scripts and styles: cache for 30 days with short browser max-age  Header set Cache-Control "public, max-age=604800, s-maxage=2592000" 

The separate rule for CSS and JS is intentional. Setting max-age=604800 (7 days) protects your visitors from holding onto stale styles forever if you push an unversioned hotfix, while s-maxage=2592000 keeps the asset cached across CDN nodes worldwide.

Combining mod_expires with mod_headers for Legacy Fallback

Some automated testing tools and older proxies still look for the HTTP/1.0 Expires header. Apache’s mod_expires calculates that date automatically based on file modification or access times.

You can see how Apache handles expiration offsets in the official Apache mod_expires documentation. When you run both modules together, mod_headers overrides any conflicting directives for modern clients, while mod_expires handles backwards compatibility.

Here is the combined block:

 ExpiresActive On ExpiresDefault "access plus 1 month" ExpiresByType image/webp "access plus 1 year" ExpiresByType image/jpeg "access plus 1 year" ExpiresByType image/png "access plus 1 year" ExpiresByType image/svg+xml "access plus 1 year" ExpiresByType font/woff2 "access plus 1 year" ExpiresByType text/css "access plus 1 month" ExpiresByType application/javascript "access plus 1 month"
   Header append Cache-Control "public, s-maxage=2592000" 

Using Header append simply tacks s-maxage onto the Cache-Control header that mod_expires already generated, giving you a clean Cache-Control: max-age=2592000, public, s-maxage=2592000.

Handling Cache Invalidation and Dynamic Content

Static assets are easy. Dynamic HTML generated by WordPress is where things get messy. If you cache HTML aggressively at the edge without rules in place, anonymous visitors will inevitably see someone else’s admin bar or cart data.

If your CDN is proxying all traffic, dynamic PHP endpoints must send no-cache or private headers unless you have tailored edge caching rules. To prevent caching authenticated sessions, make sure you bypass CDN edge cache for logged-in WordPress users.

Add this safety check to .htaccess to ensure searches and admin queries don’t get trapped in your edge CDN tiers:

 # Ensure dynamic PHP responses are not stored by shared caches  Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0" Header set Pragma "no-cache" Header set Expires "0" 

WordPress core also handles dynamic headers under the hood. The WordPress nocache_headers() API sends sensible defaults for logged-in users and wp-admin screens, which pairs nicely with the Apache rule above.

Testing and Verifying Headers with cURL

Don’t rely on your browser’s network tab for this. Local browser caches will mislead you every time. Use curl from your terminal to see the raw headers directly from your origin and from your CDN edge.

To hit your origin server directly (bypassing the CDN DNS records), use the --resolve flag with your server’s real IP address:

curl -I -s https://example.com/wp-content/themes/theme/style.css  --resolve example.com:443:192.0.2.45

Check the raw response for your configured Cache-Control directives:

HTTP/2 200
date: Thu, 20 Feb 2025 14:10:05 GMT
server: Apache
last-modified: Wed, 12 Feb 2025 09:30:12 GMT
etag: "12a4-6112d7b14e480"
accept-ranges: bytes
content-length: 4772
cache-control: public, max-age=604800, s-maxage=2592000
content-type: text/css

Once origin headers look right, make the same request through your public CDN hostname:

curl -I https://example.com/wp-content/themes/theme/style.css

Look for cache status headers like x-cache: HIT, cf-cache-status: HIT, or x-sp-cache: HIT. Your first curl might show a MISS, but running it a second time should immediately show a HIT.

Common Gotchas and Troubleshooting

The first time I set this up on a client site, static assets still showed Cache-Control: max-age=0 at the edge. After digging through Apache configs and logs, two culprits stood out.

1. Hidden Set-Cookie Headers

RFC standards dictate that shared caches must not cache any response containing a Set-Cookie header unless explicitly instructed to ignore it. A poorly written WordPress plugin will often attach tracking cookies or session IDs to static file requests or routed images.

If your CDN hit ratio sits near zero despite proper headers, check your static asset response for cookies. You can scrub these at the edge; check out how to strip Set-Cookie headers for static assets in StackPath so edge nodes actually cache your media.

2. Conflicting Caching Plugins

Plugins like W3 Total Cache, WP Super Cache, or LiteSpeed write their own rules into .htaccess. If your manual snippet sits below those plugin directives, the plugin rules run first and override yours.

Put your custom block at the absolute top of .htaccess. You can also use Header always set instead of Header set to overwrite headers established earlier in Apache’s processing chain.

Frequently Asked Questions

What is the difference between max-age and s-maxage?

max-age tells both user browsers and intermediate proxies how long a resource stays fresh. s-maxage (shared max-age) applies only to shared caches like CDNs. Browsers ignore s-maxage entirely, which lets you set a longer cache window on your edge nodes while keeping browser caches short.

Does Cache-Control override Expires?

Yes. Any modern browser or CDN proxy supporting HTTP/1.1 or HTTP/2 will prioritize Cache-Control: max-age over the older HTTP/1.0 Expires header.

Why is my CDN returning MISS even with s-maxage configured?

Usually, it’s one of three things: a stray Set-Cookie header from origin, cache-busting query parameters (like ?ver=6.4.2), or a Vary: User-Agent header forcing the edge to store separate cache copies for every device type.

Will changing .htaccess clear the files already stored in the CDN?

No. Updating .htaccess only impacts future origin fetches. Assets already sitting in edge memory will stay there until their TTL expires or you run a manual cache purge from your CDN dashboard.

Next Steps for Edge Performance

With origin headers configured, the next step is making sure your DNS routing properly feeds edge nodes. Follow our guide to connect Namecheap DNS to StackPath CDN with a Hostinger origin to finish off your caching pipeline.

all_in_one_marketing_tool