Set Cache-Control for Static Files in .htaccess
Give images, fonts, CSS and JS a long browser cache, keep HTML fresh, and avoid the WordPress ?ver= trap with a safe, tested .htaccess setup you can undo.
On this page
The short answer
Use Header set Cache-Control inside FilesMatch blocks in .htaccess. Give max-age=31536000, immutable only to files with a content hash in the file name, shorter lifetimes to plain images, fonts, CSS and JS, and no-cache to static HTML. Back up .htaccess first and confirm the result with curl -I.
You set browser caching for static files in .htaccess with Header set Cache-Control, wrapped in <FilesMatch> blocks that pick files by name. Files with a version code in the name get max-age=31536000, immutable. Plain images, fonts, CSS and JS get shorter lifetimes, and HTML gets no-cache so it always checks for a fresh copy.
Here is what surprises people: without clear caching headers, a returning visitor's browser may fetch or re-check the same logo, font and stylesheet on page after page. The server is fine. The browser was simply never told how long it may keep those files. The fix is a few lines, but one wrong line can trap visitors with an old stylesheet for a year, so we go carefully.
By the end you will have a tiered caching setup in .htaccess, a backup you can roll back to in under a minute, and a way to prove the headers work with curl and your browser.
What Cache-Control actually does
Cache-Control is an HTTP response header, a short line of text your server sends along with each file. It tells the browser whether it may store the file and for how long it may reuse it without asking again.
Think of it like a "best before" date on milk. max-age=86400 means "good for 86,400 seconds (one day), use it without checking". After that date, the browser checks with the server before using its copy again.
These are the directives (single instructions inside the header) you will use today. The definitions come from the MDN Cache-Control reference:
max-age=N: the response stays fresh for N seconds after it was generated.immutable: the response will not be updated while it is fresh.no-cache: the browser may store the response, but must check with the server before each reuse.no-store: no cache of any kind should store the response at all.
Heads up: MDN says plainly that no-cache does not mean "don't cache". It allows storing but requires revalidation (a quick "has this changed?" check with the server). If you want nothing stored, that is no-store.
Why only versioned files get a one-year lifetime
immutable is a promise. RFC 8246, the standard that defines it, says the server will not update the file during its freshness lifetime, and that clients SHOULD NOT send a conditional request (a "has this changed?" check) during that time, even on a reload, unless the user explicitly overrides it, for example with a force reload. The RFC's own example is Cache-Control: max-age=31536000, immutable, which is 365 days.
So if you put that header on style.css and then edit style.css, your regular visitors may keep the old version for up to a year. You broke the promise, and the browser believed you.
The safe way is what MDN calls the cache-busting pattern: never change a file in place; publish new content under a new URL. A fingerprinted file has a hash of its content in its name, like app.3f9a1c2b.css. New content means a new name and a fresh download.
Why WordPress ?ver= URLs are a trap
WordPress loads theme and plugin stylesheets with wp_enqueue_style(). Its $ver parameter is added to the URL as a query string "for cache busting purposes". The catch is in the default. According to the WordPress developer reference:
$verdefaults tofalse, and then WordPress adds the currently installed WordPress version.nulladds no version at all.- A string is added as given, so a theme can pass its own version number.
Why this matters: if a theme uses the default, its CSS URL looks like style.css?ver= followed by the WordPress version. That URL changes when WordPress updates, not when you edit the theme file. Tell the browser that URL is immutable for a year and your edits stay invisible to returning visitors until the next WordPress update.
There is a second, quieter problem. Apache's <FilesMatch> matches file names, and a query string is not part of a file name. So .htaccess cannot tell style.css?ver=1 from style.css?ver=2. That is why this guide gives immutable only to files with the version in the name itself.
Before you start
Plan about 20 minutes. The risk is low with a backup, because a broken .htaccess is fixed by putting the old file back.
- Your hosting control panel login (this guide uses cPanel File Manager).
- Your site runs on Apache or LiteSpeed. cPanel's article says LiteSpeed reads Apache configuration files directly and is compatible with Apache
.htaccessrules. - A terminal with
curl(installed on most macOS and Linux systems), or a free online header checker. - The URL of one image, one CSS file and one JS file on your site. On WordPress, open your homepage, view the page source and search for
.css?ver=to find them. - If you use Cloudflare, your Cloudflare dashboard login.
- A backup of
.htaccess, made in Step 2. Do not skip it.
Quick tip: if you use a cache plugin, check whether it already writes browser cache rules into .htaccess, because two sets of rules give confusing results. Our LiteSpeed Cache speed guide covers page caching, a different layer.
The tiered policy we are going to build
Different files change at different speeds, so they get different lifetimes. Here is the plan, written as a list. The one-day and 30-day numbers are my starting choices, not a standard; adjust them to how often you change things.
- Fingerprinted assets (hash in the file name, such as
app.3f9a1c2b.js):max-age=31536000, immutable, which is one year with no revalidation. - Plain images and fonts (such as
logo.pngorinter.woff2):max-age=2592000, which is 30 days. If you replace your logo, upload it under a new name. - Plain CSS and JS (including WordPress files with
?ver=):max-age=86400, which is one day. An edit reaches regular visitors within a day at most. - Static HTML files (
.htmland.htm):no-cache, so the browser always checks for a newer page. This matches MDN's pattern ofno-cachefor/index.html. - WordPress pages and anything in `wp-admin`: leave them alone. They are produced by PHP, not served as static files, so leave their headers to WordPress and your page cache.
Made-up example: Priya runs a small bakery site on WordPress. She sets every .css file to one year with immutable. A week later she changes her button colour in the child theme's style.css. She sees it, because she force reloads. Her regular customers see the old colour for months. With the tiers above, the edit would reach everyone within a day.
Set up the headers in cPanel File Manager
Step 1: Show hidden files
.htaccess starts with a dot, so File Manager may hide it. The cPanel File Manager documentation describes the setting.
- Log in to cPanel and open File Manager.
- Click Settings.
- Tick Show Hidden Files (dotfiles).
- Click Save.
You should now see .htaccess in your site's document root, the folder your domain serves files from. For a main domain this is usually public_html.
Step 2: Back up the .htaccess file
cPanel's documentation notes that you cannot create a copy of an item within the same folder, so the Copy button will not make .htaccess.bak next to the original. We make the backup by hand instead, and also keep a copy on your computer.
- Right-click
.htaccessand choose Download. Keep that file safe on your computer. - Select
.htaccessand click Edit. Confirm the encoding prompt by clicking Edit again. - Select all the text in the editor and copy it. Close the editor without changing anything.
- In the toolbar, click + File. In New File Name, type
.htaccess.bak, keep the folder as your document root, and click Create New File. - Select
.htaccess.bak, click Edit, paste the text you copied and save the file.
You should now see both .htaccess and .htaccess.bak in the folder, with the same size. If .htaccess did not exist before, that is fine too: write down that it was missing, because rollback then means deleting the new one.
Step 3: Add the caching rules
Open .htaccess with Edit. Put the new block at the very top of the file, outside any section marked with # BEGIN and # END lines, because those sections are managed by WordPress or a plugin and are not yours to edit.
The block below sets the five tiers. Each <FilesMatch> picks files by name, and Header set sends the matching Cache-Control value:
# Manual browser caching START (keep outside other BEGIN/END blocks)
<IfModule mod_headers.c>
# Tier 3: static HTML files always revalidate
<FilesMatch "\.(html|htm)$">
Header set Cache-Control "no-cache"
</FilesMatch>
# Tier 2b: plain CSS and JS, including WordPress ?ver= URLs, one day
<FilesMatch "\.(css|js|mjs)$">
Header set Cache-Control "max-age=86400"
</FilesMatch>
# Tier 2a: plain images and fonts, 30 days
<FilesMatch "\.(png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|eot)$">
Header set Cache-Control "max-age=2592000"
</FilesMatch>
# Tier 1: fingerprinted files (dot or dash plus 8 or more hex characters before the extension), one year, immutable
<FilesMatch "[.-][0-9a-fA-F]{8,}\.(css|js|mjs|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|eot)$">
Header set Cache-Control "max-age=31536000, immutable"
</FilesMatch>
</IfModule>
# Manual browser caching FINISHSave the file. Apache reads .htaccess on every request, so there is nothing to restart.
You should see your site load normally. If you get a "500 Internal Server Error" instead, jump to rollback in Step 4 right away, then read the troubleshooting section.
Here is what each piece does, so you can change it with confidence:
<IfModule mod_headers.c>means "only use these lines if the headers module is loaded". If it is missing, Apache skips the block instead of breaking the site.<FilesMatch>is a core Apache directive allowed in.htaccess. It applies the lines inside only to file names that match a regular expression (a text pattern).Header setcomes frommod_headersand, per the Apache docs, sets the header "replacing any previous header with this name".- The order matters. A file like
app.3f9a1c2b.cssmatches both the CSS block and the fingerprint block. BecauseHeader setreplaces the earlier value, I put the fingerprint block last. Treat this as a pattern you must test on your own server with the curl checks below.
Heads up: notice there is no always in these lines. The mod_headers docs explain that always adds the header "even on error". You do not want a missing image's 404 page cached for a year, so leave always out here.
Step 4: Rollback, if anything goes wrong
Keep this step handy. It takes under a minute.
- In File Manager, select
.htaccessand click Rename. Name it.htaccess.broken. - Select
.htaccess.bakand click Rename. Name it.htaccess. - Reload your site. It should be back to how it was before.
If you started without any .htaccess, just rename the new one to .htaccess.broken. Once you have fixed the problem, delete .htaccess.broken so stray files do not pile up.
Step 5: Check the fingerprint pattern fits your files
Look at the file names your theme or build tool actually produces. The pattern above matches a dot or dash followed by at least eight hex characters (0 to 9 and a to f) right before the extension, such as main-a1b2c3d4.js.
If your hashes use other characters, those files simply fall back to the one-day or 30-day tier. That is safe, just less fast. Never loosen the pattern so far that plain names like style.css match. Beware names that only look like hashes: IMG-20240101.jpg has eight digits, which count as hex, so it gets the one-year tier.
Why not mod_expires?
You may find older guides that use mod_expires with lines like ExpiresByType image/png "access plus 1 month". It works, but the Apache expires module documentation says it controls the Expires header and only the max-age part of Cache-Control. To set other directives, such as immutable, the same page points you to the Header directive. That is why this guide uses mod_headers for everything.
If your .htaccess already has an <IfModule mod_expires.c> block, you now have two systems setting lifetimes. The mod_headers docs say Header directives run just before the response is sent, so your Cache-Control should win, but an old Expires header may ride along. Remove the old ExpiresByType lines for the types you now cover, testing after each change.
How to check it worked
Check with curl
The -I option asks only for the headers, not the file itself. Run this against a plain image on your site:
curl -I https://example.com/wp-content/uploads/logo.pngYou should see a line like this among the headers. Over HTTP/2 the header name may be printed in lowercase:
cache-control: max-age=2592000Now check a WordPress stylesheet with its version string. The query string should not change the result:
curl -I "https://example.com/wp-content/themes/example-theme/style.css?ver=1.0"Expected result:
cache-control: max-age=86400Then check a fingerprinted file, if your site has one:
curl -I https://example.com/assets/app.3f9a1c2b.jsExpected result:
cache-control: max-age=31536000, immutableQuick tip: always test the final https:// address. If you test http:// and see a 301 status with no Cache-Control, you are looking at the redirect, not the file. Our HTTP to HTTPS redirect guide explains that hop, and the gzip and Brotli curl check uses the same command for compression.
Check in the browser
- Open your site in Chrome, Edge or Firefox and open the developer tools.
- Go to the Network tab and reload the page.
- Click on a CSS file, image or font in the list and look at its response headers. You should see the same
Cache-Controlvalue curl showed. - Reload the page normally (not a force reload). Files within their lifetime should now load from the browser's cache instead of the network.
Heads up: the Network tab has an option to disable the cache while developer tools are open. Make sure it is switched off for step 4, or you will think caching is broken.
Troubleshooting
"500 Internal Server Error" right after saving
Symptom: every page shows "Internal Server Error" or a blank 500 page.
Likely cause: a typo in the block, such as a missing </FilesMatch>, a missing quote, or a directive your host does not allow in .htaccess. The Apache .htaccess guide says your error log "will likely tell you that the directive used in your .htaccess file is not permitted", or give a message specific to the syntax error.
Fix: roll back with Step 4 first, so the site is up. Then check your server error log (your host can tell you where it is) for the line naming .htaccess, fix it and try again. Paste the block instead of retyping it.
The Cache-Control header does not show up at all
Symptom: curl returns 200 but no Cache-Control line, or a different value from the one you set.
Likely causes and fixes:
- You edited the wrong
.htaccess. Check you are in the document root of the domain you are testing. - A cache or CDN sits in front and is serving an old stored copy of the headers. Clear your cache plugin's cache, and if you use Cloudflare, purge that file's URL from its cache, then test again.
- The host does not allow these directives in
.htaccess. The Apache docs list theHeaderdirective under theFileInfooverride, so the server must allow that for your folder. If nothing you add changes the headers, ask your host to confirm. mod_headersis not loaded, so the<IfModule>wrapper skipped the whole block silently. Your host can confirm and enable it.
A page shows old CSS after you edited a file
Symptom: you changed style.css, you see the change, but a colleague or customer still sees the old design.
Likely cause: the file name did not change, so their browser is still inside the lifetime you set. You see the new version because you force reloaded or logged in with a cleared cache. RFC 8246 says browsers should skip the "has this changed?" check during an immutable lifetime unless the user explicitly overrides it, for example with a force reload.
Fix: give changed files a new URL. In WordPress, bump the theme's version number so the ?ver= value changes. For a plain file, upload it under a new name and update the reference. If you had put immutable on an unversioned file, remove it now, because visitors who already cached it will keep it until their copy expires.
Cloudflare shows a different Cache-Control than your server
Symptom: curl through your domain shows a longer browser lifetime than the one in .htaccess.
Likely cause: Cloudflare's Browser Cache TTL setting. Its documentation says Cloudflare replaces the origin's header when your Cache-Control value is lower than the Browser Cache TTL, or when your server sends no Cache-Control or Expires at all.
Fix: at the time of writing (18 October 2026), in the Cloudflare dashboard you open the Caching page and, under Browser Cache TTL, choose Respect Existing Headers. Cloudflare's docs say this tells Cloudflare to honor the Cache-Control headers from your origin web server. Then check your Cache Rules for any browser TTL value too. See the Cloudflare Browser Cache TTL docs for the current steps.
You see two caching signals, or an odd Expires date
Symptom: curl shows your Cache-Control plus an Expires header with an unexpected date.
Likely cause: an old mod_expires block or a cache plugin's rules are still active in the same file.
Fix: search .htaccess for ExpiresByType and Header set Cache-Control, keep one system per file type, and remove extra lines one at a time, testing with curl after each.
Security notes and when to ask for help
This guide only sets how long browsers keep public files. Security headers like HSTS belong in their own block; our HSTS and security headers guide covers them.
Never give long lifetimes to anything personal: account pages, carts, checkout steps or wp-admin. That is why this guide does not set any rule for WordPress pages or PHP files.
Ask your host or a developer if the error log mentions an override you cannot change, if mod_headers is not available, or if you run a build pipeline whose hash format you are not sure about.
The one message worth forwarding
Copy this to your developer or host if you want someone else to do it, or to check your work:
"Hi, could you help me set browser caching for static files on our site (example.com)? I would like Cache-Control: max-age=31536000, immutable only for files with a content hash in the file name, max-age=2592000 for other images and fonts, max-age=86400 for other CSS and JS (including WordPress files with ?ver=), and no-cache for static .html files. Please do not cache WordPress pages or wp-admin, do not use always on these headers, and back up .htaccess first. If we use Cloudflare, please set Browser Cache TTL to Respect Existing Headers. Thanks!"
The takeaway: a long cache is a promise that the file will never change, so only make that promise for files whose name changes when their content does.
Reader questions
Does no-cache mean the browser will not cache the file?
No. According to MDN, no-cache lets the browser store the file but makes it check with the server before each reuse. If you want nothing stored at all, the directive is no-store.
Can I put immutable on WordPress files that end in ?ver=?
It is risky. By default WordPress adds the installed WordPress version to that query string, so the URL changes only when WordPress updates, not when you edit the theme file. Use immutable only when the version in the URL changes every time the file changes.
Why use Header set instead of ExpiresByType?
The Apache expires module only controls the Expires header and the max-age part of Cache-Control. Its own documentation points to the Header directive for other directives such as immutable.
Will these rules work on a LiteSpeed server?
cPanel says LiteSpeed reads Apache configuration files and is compatible with Apache .htaccess rules. Still test the headers with curl after saving, as you would on Apache.
How do my visitors get a changed file if it is cached for a year?
They get it when the file has a new URL, which is why the one-year rule is only for files with a hash in the name. A user can also force reload, which RFC 8246 allows to skip the immutable lifetime.
Should I add always to the Header lines?
Not for this setup. The Apache docs say always adds the header even on error responses, and you do not want an error page cached for a long time.
Sources & further reading
- MDN: Cache-Control header reference
- RFC 8246: HTTP Immutable Responses
- Apache HTTP Server 2.4: headers module (mod-headers)
- Apache HTTP Server 2.4: expires module (mod-expires)
- Apache HTTP Server 2.4: core directives (FilesMatch, IfModule)
- Apache HTTP Server 2.4: .htaccess files how-to
- cPanel documentation: File Manager
- cPanel support: What is LiteSpeed Web Server
- WordPress developer reference: enqueue style function
- Cloudflare docs: Browser Cache TTL
Originally published . About our editorial updates.


