2026.07 / STATIC SITE OPERATIONS
Cache-Control for Static HTML Behind Cloudflare: Fresh Pages, Fast Assets
A new post is live at the origin, but a reader gets yesterday’s homepage or a broken-looking mix of new HTML and old CSS. That is usually not a Cloudflare problem to “solve” by purging everything. It is a cache-policy problem: HTML and versioned assets have different jobs and should not share one lifetime.
This is a practical policy for a small static site served by nginx and fronted by Cloudflare. It answers the long-tail question I keep seeing in deploy incidents: what Cache-Control headers should static HTML, CSS, and JavaScript use so a release is fresh without throwing away CDN performance?
Cache documents conservatively. Cache assets aggressively only when their URL changes with their contents.
The policy in one table
| Response | Useful starting policy | Why |
|---|---|---|
| HTML, RSS, sitemap, robots | public, max-age=300 | Five minutes is short enough for a normal publishing loop while still allowing browser and edge reuse. |
| CSS and JavaScript with a bumped version or content hash | public, max-age=2592000, immutable | Clients can reuse the same immutable URL for 30 days because a changed file gets a changed URL. |
| Images with stable filenames that might be replaced | Shorter TTL, or rename/version them | immutable is a promise; do not make it for a URL whose bytes may change. |
The exact numbers are a product decision, not a law. The split is the important part. MDN describes max-age as a freshness lifetime and immutable as a signal that a fresh response will not change during that lifetime. Cloudflare’s cache documentation recommends targeted purges such as a single URL when a purge is genuinely needed. Those two ideas fit well together: use predictable URLs and headers first; use a narrow purge for the exception.
Why HTML should not be immutable
For a hand-maintained blog, /, post pages, feed.xml, and sitemap.xml are the discovery surface. They can change at the same URL. Sending a year-long immutable header for them makes a deploy look unreliable even when the container is correct.
A small shared default works well in nginx:
server {
# Other server and security settings omitted.
add_header Cache-Control "public, max-age=300" always;
location / {
try_files $uri $uri/ =404;
}
}
This lets ordinary documents revalidate or become fresh again quickly. It also means a new page does not require a global CDN purge just because the homepage now links to it. If the site has a genuine editorial requirement for instant HTML changes, reduce the value further or use targeted invalidation; do not silently turn every file into a long-lived immutable object.
Long-lived assets require changed URLs
Long caching pays off most for CSS and JavaScript. The safe condition is simple: when the contents change, the public URL must change too. A build system may produce app.3f8c2a.css. A small hand-written site can use an explicit version query:
<link rel="stylesheet" href="/styles.css?v=20260711-cache1">
<script src="/script.js?v=20260711-cache1"></script>
Then nginx can give those asset responses a long freshness lifetime:
location ~* \.(css|js|png|jpg|jpeg|gif|svg|webp|ico)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable" always;
try_files $uri =404;
}
Do not add immutable to /styles.css if you plan to overwrite that URL and leave every HTML page pointing to it. A query string is part of the cache key in normal HTTP caching, but it only helps if the HTML references the new string. Prefer filename hashes in a generated site; a deliberate query bump is a reasonable low-tech equivalent for a manual one.
Cloudflare: origin headers first, purge second
Cloudflare may cache at the edge according to your configuration and origin response headers. Before changing a setting, inspect the actual response rather than relying on a dashboard toggle or one browser tab:
curl -sSI https://example.com/ | grep -Ei 'cache-control|age|cf-cache-status'
curl -sSI 'https://example.com/styles.css?v=20260711-cache1' | grep -Ei 'cache-control|age|cf-cache-status'
The first request after an edge miss can show a different cache status from later requests. That is normal. What matters is that the HTML response has the short policy and the versioned asset has the long policy you intended. Test the body too when debugging a release:
curl -fsS 'https://example.com/?v=release-check' | grep 'Expected new heading'
curl -fsS 'https://example.com/?v=release-check' | grep 'styles.css?v=20260711-cache1'
If an emergency purge is necessary, purge the one changed URL or a deliberately scoped set. A full purge drops useful cached content for every visitor and can hide the real issue: an unchanged asset URL, a deployment that never ran, or a Cloudflare cache rule that overrides the origin.
Release sequence for a static blog
- Change HTML and update the CSS/JS URL if those assets changed.
- Build the same nginx image that production will run.
- Fetch local HTML and assets; confirm the post exists and the expected asset version is referenced.
- Push and wait for the named Coolify deployment to finish.
- Fetch the live post, homepage,
feed.xml, andsitemap.xmlwith a harmless query string. - Inspect live cache headers. Only then use a targeted Cloudflare purge if a specific URL remains stale beyond its policy.
This fits the broader deploy-debugging approach: a Git commit, a successful Coolify deployment, an origin response, and an edge response are separate checks. Treating them as one event makes cache failures look mysterious.
Two common mistakes
“Disable caching everywhere”
This makes a one-off stale page disappear but gives up a major benefit of a static site. Keep cacheable assets cacheable. Keep documents short-lived enough for your publishing cadence.
“Purge everything after every commit”
A routine full purge turns cache invalidation into an operational dependency and can create a performance dip for visitors. It is a poor substitute for versioned assets. Reserve it for a real incident, and prefer the narrowest purge Cloudflare supports.
A boring policy is a reliable policy
Static-site caching does not need a complicated stack. Give changeable documents a short TTL. Give content-addressed or deliberately versioned assets a long immutable TTL. Verify headers and live content after each release. With that division in place, Cloudflare becomes a useful edge cache rather than another guess in the deployment chain.