Nginx Caching: How to Cache Static Assets and API Responses Without Stale Data

13 min read 2,452 words
Nginx Caching: How to Cache Static Assets and API Responses Without Stale Data featured image

Nginx sits in front of your application server and handles thousands of requests per day. Without caching, every request travels through to your backend, loads a result from the database or file system, and sends it back to the client. That works at low traffic volumes. Once traffic increases, the backend becomes the bottleneck and response times climb even though the server is serving the same responses repeatedly.

Nginx caching lets you store those repeated responses at the server level, reducing backend load and delivering faster responses to users. This guide covers how to configure caching for static assets, dynamic PHP pages via FastCGI, and API responses through proxy caching, along with practical strategies for keeping cached content fresh without introducing stale data issues.

Cache Headers: The Foundation

Before configuring Nginx caching itself, set the correct cache-control headers on your responses. The Cache-Control header tells browsers and intermediate proxies how long to keep a copy locally. Getting these headers right upstream prevents caching conflicts and unexpected behaviour in your Nginx cache layer.

For static assets, set a long expiry. These files change only when you deploy new versions, not on every request. A typical configuration uses max-age=31536000 with a content hash in the filename so that any file change produces a new URL that bypasses the cache automatically.

location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

Setting immutable tells browsers that the file will never change at that URL, which suppresses conditional revalidation requests entirely. Combined with cache-busting filenames, this is the most efficient way to handle static assets.

For dynamic pages, set Cache-Control: no-cache, no-store, must-revalidate. This prevents browsers from caching sensitive or frequently changing content.

add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache";
add_header Expires "0";

API responses should generally be uncached unless you have explicitly designed them for caching. Returning cached API data when the client expects fresh data causes subtle bugs that are difficult to diagnose.

For content that changes but not continuously, such as a blog post listing or a product catalog, use stale-while-revalidate. This serves stale content while fetching fresh content in the background, which gives users fast responses while keeping content reasonably current.

add_header Cache-Control "public, max-age=300, stale-while-revalidate=60";

FastCGI Cache for PHP Applications

FastCGI caching stores the output of PHP scripts and other FastCGI backends directly in Nginx, without touching the application server on repeat requests for the same URL. This significantly reduces PHP-FPM worker consumption and database load for pages that receive repeated traffic.

Configure a cache zone in the http block of your Nginx configuration:

fastcgi_cache_path /var/cache/nginx/fcgi levels=1:2 keys_zone=FCGI:10m max_size=1g inactive=60m use_temp_path=off;

This creates a 1 gigabyte cache store with a 10 megabyte keys zone in memory. The inactive timer means any cache entry not accessed for 60 minutes is evicted regardless of its max-age. Setting use_temp_path=off writes cache files directly to the cache directory, avoiding an unnecessary copy operation.

Apply the cache to location blocks that proxy to PHP-FPM:

location ~ \.php$ {
    fastcgi_pass unix:/run/php/php-fpm.sock;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    include fastcgi_params;

    fastcgi_cache FCGI;
    fastcgi_cache_valid 200 5m;
    fastcgi_cache_valid 404 1m;
    fastcgi_cache_valid 500 0s;
    fastcgi_cache_use_stale error timeout updating http_500;
    fastcgi_cache_lock on;
    fastcgi_cache_background_update on;
    add_header X-FastCGI-Cache $upstream_cache_status always;
}

The X-FastCGI-Cache header returns HIT, MISS, or BYPASS so you can verify the cache is working from browser developer tools or curl output. The always parameter on the add_header directive ensures the header appears even on error responses, which is useful for monitoring.

Setting fastcgi_cache_lock on prevents multiple simultaneous requests for the same uncached URL from all hitting the upstream at once. When the first request is in flight, subsequent requests for the same URL wait for the first one to complete rather than each spawning their own upstream request. Combined with fastcgi_cache_background_update on, stale content can be served while fresh content is being fetched in the background.

For pages that must not be cached, set fastcgi_cache_bypass based on a cookie or request header:

set $skip_cache 0;
if ($cookie_session ~* "^[a-zA-Z0-9_]+$") {
    set $skip_cache 1;
}
if ($request_uri ~* "/wp-admin/|/checkout/|/my-account/") {
    set $skip_cache 1;
}

fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;

Failing to exclude authenticated pages from FastCGI cache is one of the more embarrassing launch-day incidents. Users end up seeing each other's session data, which is a serious privacy and security issue.

Proxy Cache for API Responses

For API responses that are expensive to generate but change infrequently, the proxy_cache directive works the same way as FastCGI cache but for proxied HTTP responses from any upstream, including other web servers, microservices, or external APIs you want to cache locally to reduce latency.

proxy_cache_path /var/cache/nginx/api levels=1:2 keys_zone=API:10m max_size=500m inactive=10m;

proxy_cache_key "$scheme$request_method$host$request_uri$http_authorization";

location /api/ {
    proxy_pass http://127.0.0.1:8080;
    proxy_cache API;
    proxy_cache_valid 200 10m;
    proxy_cache_valid 404 30s;
    proxy_cache_valid 500 0s;
    proxy_cache_lock on;
    proxy_cache_use_stale updating error timeout;
    add_header X-Cache-Status $upstream_cache_status always;

    proxy_cache_bypass $http_cache_control;
    proxy_no_cache $http_cache_control;
}

Including the authorization header in the cache key ensures that authenticated responses are cached separately from unauthenticated ones. Without this, you risk serving user-specific data to other users.

The stale-while-revalidate and stale-if-error directives extend this further:

proxy_cache_background_update on;
proxy_cache_revalidate on;
proxy_cache_use_stale error timeout updating;
proxy_cache_lock on;

Cache Invalidation Strategies

Cache invalidation is where most Nginx caching strategies fall apart. There are three practical approaches, each with different trade-offs around complexity, safety, and staleness.

Time-Based Expiry

The simplest approach is to set a reasonable max-age and accept that users may see slightly stale content for the duration. For many use cases, such as blog posts, product listings, or news feeds, a few minutes of staleness is perfectly acceptable. Setting fastcgi_cache_valid 200 10m means every cached entry is treated as fresh for 10 minutes before the next request triggers a fresh fetch.

This approach requires no application changes and is the most reliable in production environments because there is no risk of cache corruption. The downside is that you cannot push content updates to users until the cache expires naturally.

Targeted Purge with PURGE Requests

When you need immediate invalidation for specific URLs, use the proxy_cache_purge directive. When content changes in your application, issue a PURGE request to the same URL. Nginx treats a PURGE request as a signal to delete the cached entry for that URL.

proxy_cache_path /var/cache/nginx/api levels=1:2 keys_zone=API:10m max_size=500m inactive=10m;

map $request_method $purge_method {
    PURGE   1;
    default 0;
}

location /api/ {
    proxy_pass http://127.0.0.1:8080;
    proxy_cache API;
    proxy_cache_valid 200 10m;

    proxy_cache_purge $purge_method;
}

Restrict PURGE requests to your application servers by IP using allow and deny directives:

location /api/ {
    allow 127.0.0.1;
    allow 10.0.0.0/8;
    deny all;

    proxy_cache_purge $purge_method;
}

Without this restriction, anyone who discovers a cached URL can purge your cache, causing a thundering herd of requests to hit your backend simultaneously.

Cache Versioning via URL Keys

Cache versioning separates content updates from cache invalidation. Append a version identifier to your cache key and change the version when content updates. Old entries expire naturally via the inactive timer without any purging logic.

Store the current version in a variable, often set by a map directive based on the request URI:

map $uri $cache_version {
    ~^/api/products/   "v2";
    ~^/api/categories/ "v1";
    default            "v1";
}

proxy_cache_key "$scheme$request_method$host$request_uri?version=$cache_version";

When you deploy a product catalog update, increment the version number in the map. Nginx will immediately start caching under the new key while the old entries age out naturally. This approach works particularly well for content that changes significantly but infrequently, such as pricing data or product listings.

Common Nginx Caching Mistakes

Several recurring mistakes cause caching to fail in ways that are difficult to diagnose in production.

Caching POST requests: POST requests carry data to the server and should not be repeated automatically. If you find yourself wanting to cache a POST response, the real question is whether that endpoint should be a GET instead. GET requests are idempotent by definition and safe to cache. POST requests are not.

Ignoring upstream cache headers: Setting cache-control headers in Nginx location blocks without considering upstream settings is a frequent error. If your PHP application sets Cache-Control: no-store, Nginx's FastCGI cache will ignore it by default unless you explicitly configure fastcgi_ignore_headers Cache-Control. Understand what your upstream is setting before adding Nginx caching on top.

Undersized cache with high eviction rates: Not monitoring cache hit rate is the most common operational mistake. A cache that is too small and constantly evicting entries is worse than no cache at all, because the extra disk IO adds latency without the benefit of cache hits. Monitor the X-FastCGI-Cache or X-Cache-Status header in your analytics, and alert on hit rates below your expected threshold.

Cache keys missing vary headers: If your application returns different content based on request headers such as Accept-Language or User-Agent, include those headers in your cache key. Without the correct Vary header handling, users may receive cached content intended for a different language or device.

proxy_cache_key "$scheme$request_method$host$request_uri$http_accept_language";

Forgetting to warm the cache after restart: When Nginx restarts, the cache disk persists but the in-memory keys zone is rebuilt as entries are accessed. On a large cache, this means the first requests after a restart hit the backend until the keys zone repopulates. Consider a cache warmup script that fetches your most popular URLs after a restart to pre-populate the keys zone.

Verifying Cache Behaviour

Test caching behaviour by making two requests to the same URL and comparing response headers and timing. A cached response should be significantly faster, typically under 10 milliseconds versus 100 to 500 milliseconds for a dynamic response. The cache status header confirms whether the response came from cache or was generated fresh.

curl -I https://example.com/page/
curl -I https://example.com/page/

Look for the X-FastCGI-Cache or X-Cache-Status header in the response. The first request should return MISS, and the second should return HIT.

For testing invalidation, make a change to your application content and issue a PURGE request:

curl -X PURGE https://example.com/page/

The next request should return MISS, confirming the cache was cleared.

Monitoring Cache Hit Rates in Production

Expose cache hit rate metrics using Nginx's stub status module or by parsing access logs. The stub status module provides a simple endpoint showing active connections, request counts, and cache hit or miss rates.

location /nginx_status {
    stub_status on;
    allow 127.0.0.1;
    deny all;
}

The stub status output shows reads and writes separately, making it straightforward to calculate hit rate:

Active connections: 42
server accepts handled requests
 1234567 1234567 8901234
Reading: 0 Writing: 1 Waiting: 41

For more detailed metrics, log the cache status header per request and aggregate in your logging pipeline. Alert on sudden drops in hit rate, which usually indicate either a cache flush event or a surge in requests for URLs that were previously cached but have now expired.

Nginx Caching and CDN Considerations

When your infrastructure includes a CDN such as Cloudflare in front of Nginx, caching decisions interact across multiple layers. Setting aggressive cache headers on your origin Nginx server gives the CDN permission to cache at the edge, reducing origin traffic further. Without correct headers, the CDN may still cache responses unintentionally or, more likely, fail to cache responses you intended to be cached.

If you are running Nginx primarily as a reverse proxy or load balancer in front of application servers, combining Nginx caching with load balancing distribution lets you cache responses across multiple upstream servers efficiently. You can read more about this approach in the Nginx load balancing configuration guide.

For static asset delivery specifically, a CDN handles edge caching and global distribution more effectively than origin Nginx caching alone. The CDN setup guide for business websites covers how to configure CDN caching headers to work alongside your origin server.

Frequently Asked Questions

How do I clear a specific Nginx cache entry?
Use the proxy_cache_purge directive to enable PURGE requests, then issue a PURGE request to the exact URL of the entry you want to clear. This requires restricting PURGE access to trusted IPs only. Alternatively, wait for the inactive timer to expire the entry, or increment a cache version key to start fresh entries under a new key while old entries expire naturally.
Does Nginx caching affect SEO?
Nginx caching itself does not affect SEO, but stale content served from cache can. If search engine bots receive cached pages that are out of date, search indexes may reflect stale content. Setting appropriate cache expiry times and using cache invalidation when content changes ensures search engines see fresh content without sacrificing the performance benefits of caching for regular users.
Can I cache responses from authenticated endpoints?
Authenticated responses should only be cached when each user receives unique content. Include authentication identifiers in your cache key so that each user gets their own cached version. Never cache authenticated responses with a shared key, as this would allow users to see each other's data. For most applications, it is safer to bypass the cache for authenticated requests entirely.
What happens to cached responses when Nginx restarts?
The cached files on disk persist through Nginx restarts. The in-memory keys zone is rebuilt as requests access cache entries. On a cold start with a large cache, this means the first requests for each entry hit the backend until the keys zone repopulates. Running a cache warmup script after restart that fetches your most popular URLs helps avoid latency spikes during the warmup period.
How do I monitor Nginx cache performance?
Add the X-FastCGI-Cache or X-Cache-Status header to responses and log it in your access logs. Parse these logs to calculate hit rate over time. The Nginx stub status module provides a basic metrics endpoint at /nginx_status. For production monitoring, export these metrics to a time-series database and alert when hit rate drops below your expected threshold.