When a browser has a fresh copy of a file, it does not ask your server again. That makes repeat visits faster and cuts the number of requests Apache handles. You control this with two headers: Expires and Cache-Control. This guide sets them with mod_expires and mod_headers, with long lifetimes for static assets and short ones for pages that change.
Before you start
- Apache 2.4 on Ubuntu, Debian, AlmaLinux or Rocky Linux, with
sudoaccess. - Know how your site names its CSS and JS files. Long caching is safe only if a file's name or query string changes when its content changes, for example
app.3f9a1c.cssorstyle.css?ver=6.5. WordPress and most build tools do this already.
How the headers work
Cache-Control: max-age=31536000tells the browser the file is fresh for that many seconds (here, one year).Expiresgives a fixed date. It is the older header; when both are present,Cache-Controlwins.Cache-Control: no-cachedoes not mean "do not cache". It means "check with the server before using the copy". Apache answers such checks with a quick304 Not Modifiedif nothing changed.immutabletells browsers not to recheck at all during the lifetime, even on reload.
mod_expires writes both Expires and a matching max-age from simple rules. mod_headers lets you add the extra words like public or immutable.
Enable the modules
Ubuntu and Debian
sudo a2enmod expires headers
sudo systemctl restart apache2AlmaLinux and Rocky Linux
Both modules are loaded by default. Confirm with:
httpd -M | grep -E "expires|headers"Add caching rules
Create /etc/apache2/conf-available/caching.conf (Ubuntu and Debian) or /etc/httpd/conf.d/caching.conf (AlmaLinux and Rocky). You can also put the same lines in a single site's <VirtualHost> or .htaccess.
<IfModule mod_expires.c>
ExpiresActive On
ExpiresDefault "access plus 1 hour"
# Pages and data: keep fresh
ExpiresByType text/html "access plus 0 seconds"
ExpiresByType application/json "access plus 0 seconds"
ExpiresByType application/xml "access plus 1 hour"
# Versioned assets: cache for a long time
ExpiresByType text/css "access plus 1 year"
ExpiresByType text/javascript "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
# Images and fonts
ExpiresByType image/webp "access plus 6 months"
ExpiresByType image/avif "access plus 6 months"
ExpiresByType image/jpeg "access plus 6 months"
ExpiresByType image/png "access plus 6 months"
ExpiresByType image/svg+xml "access plus 6 months"
ExpiresByType image/x-icon "access plus 1 month"
ExpiresByType font/woff2 "access plus 1 year"
</IfModule>On Ubuntu and Debian enable it:
sudo a2enconf cachingWarning: If your CSS and JS do not change name when edited, a one-year lifetime means returning visitors keep the old version. Use a shorter value, such as access plus 1 week, until you add versioning.
Add Cache-Control details with mod_headers
Mark fingerprinted assets as immutable and make sure HTML is always rechecked. Add this below the expires block:
<IfModule mod_headers.c>
<FilesMatch "\.(css|js|woff2)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<FilesMatch "\.(html|php)$">
Header set Cache-Control "no-cache"
</FilesMatch>
</IfModule>Header set replaces whatever mod_expires wrote for those files, so the two do not conflict.
Do not add long cache headers to pages that show logged-in content, carts or account pages. Most apps send their own Cache-Control: private or no-store for those; leave them alone.
Reload Apache
sudo apachectl configtest
sudo systemctl reload apache2 # Ubuntu and Debian
sudo systemctl reload httpd # AlmaLinux and RockyCheck it works
Check the headers of a stylesheet:
curl -sI https://example.com/wp-includes/css/dashicons.min.css | grep -iE "cache-control|expires"You should see something like:
Cache-Control: public, max-age=31536000, immutable
Expires: <a date one year ahead>Then check the home page:
curl -sI https://example.com/ | grep -i cache-controlIt should show no-cache, or whatever your application sends. In the browser, open developer tools, load the page twice and look at the Network tab: cached files show (memory cache) or (disk cache) instead of a size.
Troubleshooting
- No
ExpiresorCache-Controlheader at all:ExpiresActive Onis missing, the module is not enabled, or the file's MIME type does not match any rule. Compare with the response'sContent-Type. - Visitors see an old stylesheet after a change: the file name did not change. Add a version string, or lower the lifetime and wait for old copies to expire.
- Headers are right on Apache but different in the browser: a CDN or caching plugin rewrites them. Check its settings, or request the server directly by IP with a
Hostheader. Invalid command 'ExpiresActive':mod_expiresis not loaded. Enable it and restart.- Dynamic pages cached by mistake: remove
phpfrom any long-lived rule. Never give HTML a long lifetime.
Next steps
- Shrink what you send: Enable gzip compression in Apache.
- Add security headers in the same place: Harden Apache.
