There’s a specific moment I remember from a site audit a while back — running a PageSpeed test and seeing “Leverage browser caching” flagged as a major issue, with dozens of static assets being re-downloaded on every single page view. The fix took about ten minutes: enabling mod_expires. That’s genuinely one of the best time-to-impact ratios in web performance work, and I set it up on every site I manage now, as a matter of habit.
Here’s exactly how I configure it.
What Is mod_expires
mod_expires is an Apache module that controls the Expires and Cache-Control: max-age HTTP headers, telling browsers (and any caching proxies or CDNs in between) how long they’re allowed to reuse a cached copy of a resource before checking back with the server.
Without these headers, browsers have to guess how long to cache something, and that guess is often way too conservative — leading to unnecessary repeat downloads of assets like logos, stylesheets, and scripts that rarely change.
Prerequisites
- Apache installed and running
- Root or sudo access
- Comfort editing Apache config or
.htaccessfiles
Step 1: Enable mod_expires
On Debian/Ubuntu:
sudo a2enmod expires
sudo systemctl restart apache2
On CentOS/RHEL, mod_expires is typically built into the base install — just confirm it’s loaded:
apachectl -M | grep expires
If it’s missing, ensure the module is loaded in your httpd.conf or conf.modules.d/:
LoadModule expires_module modules/mod_expires.so
Step 2: Basic Configuration
I usually put this in a virtual host block or a dedicated config file. Here’s the setup I use on most sites:
<IfModule mod_expires.c>
ExpiresActive On
# Default expiration
ExpiresDefault "access plus 1 month"
# Images
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 year"
ExpiresByType image/x-icon "access plus 1 year"
# CSS and JavaScript
ExpiresByType text/css "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
ExpiresByType text/javascript "access plus 1 month"
# Fonts
ExpiresByType font/woff "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
ExpiresByType application/vnd.ms-fontobject "access plus 1 year"
ExpiresByType font/ttf "access plus 1 year"
# HTML (short cache, since content changes)
ExpiresByType text/html "access plus 0 seconds"
# Data formats
ExpiresByType application/json "access plus 0 seconds"
ExpiresByType application/xml "access plus 0 seconds"
</IfModule>
Understanding ExpiresActive, ExpiresDefault, and ExpiresByType
ExpiresActive On— turns the module on for the current scope; without this, none of the other directives do anything.ExpiresDefault— sets a fallback expiration for any file type not explicitly listed.ExpiresByType— sets expiration for a specific MIME type, overriding the default for that type.
The Expiration Syntax
The expiration string follows the pattern <base> plus <number> <unit>:
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType text/css "modification plus 1 week"
access— measured from when the client last accessed the filemodification— measured from when the file was last modified on the server
I use access almost exclusively — it’s simpler to reason about and works well for typical static asset caching.
Why HTML Gets Special Treatment
Notice that I set text/html to access plus 0 seconds. This is intentional: HTML pages tend to change (content updates, new posts, price changes), so I don’t want browsers holding onto stale copies. Static assets like images, CSS, and JS, on the other hand, rarely change in place — and when they do, I use cache-busting filenames (like style.a1b2c3.css) rather than relying on cache expiration to catch the update.
Per-Directory Configuration
Sometimes I only want aggressive caching applied to a specific assets folder:
<Directory /var/www/example.com/public_html/assets>
<IfModule mod_expires.c>
ExpiresActive On
ExpiresDefault "access plus 1 year"
</IfModule>
</Directory>
Using .htaccess (When You Don’t Have Access to Main Config)
On shared hosting, or when I don’t have access to the main Apache config, I set this up in .htaccess instead:
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType text/css "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
</IfModule>
Note that AllowOverride for the relevant directory needs to include Indexes or All for this to take effect in .htaccess.
Testing and Reloading
sudo apachectl configtest
sudo systemctl reload apache2 # Debian/Ubuntu
sudo systemctl reload httpd # CentOS/RHEL
Verifying Caching Headers
curl -I https://example.com/assets/logo.png
Look for:
Cache-Control: max-age=31536000
Expires: Mon, 26 Jul 2027 12:00:00 GMT
I also check this in browser dev tools under the Network tab — the “Size” column will show “(disk cache)” or “(memory cache)” for repeat requests to properly cached assets.
Combining with mod_headers for Cache-Control
mod_expires sets the Expires header and a basic Cache-Control: max-age, but I often pair it with mod_headers for more explicit control, especially around public/private and immutable directives:
<IfModule mod_headers.c>
<FilesMatch "\.(jpg|jpeg|png|webp|gif|svg|css|js|woff2?)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
The immutable directive tells supporting browsers not to even revalidate the file until the max-age expires — useful for assets with cache-busting filenames that truly never change once published.
Real-World Use Cases
- Image-heavy sites: Photography portfolios, e-commerce product pages — huge repeat-visit speed gains.
- Sites with a CDN in front: Proper
Expires/Cache-Controlheaders tell the CDN how long to hold onto cached copies too. - Single-page apps: Long-cache the built JS/CSS bundles (with hashed filenames), short-cache the
index.html. - Documentation sites: Static assets rarely change; long cache lifetimes meaningfully reduce repeat bandwidth.
Troubleshooting Tips
- Headers not appearing at all: Confirm the module is loaded (
apachectl -M | grep expires) and thatExpiresActive Onis actually in scope for the request path. - Users seeing stale content after a deploy: This usually means static assets were cached too aggressively without a cache-busting filename strategy — always version your asset filenames when using long expiration times.
.htaccessrules not applying: CheckAllowOverridesettings for that directory in the main config.- CDN not respecting your cache headers: Some CDNs override origin cache headers with their own settings — check your CDN’s cache configuration separately.
Common Mistakes to Avoid
- Long-caching HTML pages — leads to visitors seeing outdated content after updates.
- Setting a long cache time on assets without a cache-busting strategy — you’ll be stuck waiting out the cache duration to push urgent fixes.
- Forgetting to test with
curl -Iand assuming headers are set correctly without verifying. - Confusing
accessvsmodificationbase —modificationcan behave unexpectedly if file timestamps aren’t reliable (e.g., after a fresh deploy that touches all files). - Not pairing with
mod_headerswhen more explicitCache-Controlbehavior (likeimmutableorno-storefor sensitive pages) is needed.
Security Best Practices
- Never long-cache pages containing sensitive or personalized data — use
Cache-Control: no-storefor things like account pages or checkout flows. - Be cautious caching API responses that include user-specific or sensitive data; scope caching rules narrowly to genuinely static, public content.
- Review cache headers on any page behind authentication to make sure private data isn’t inadvertently cached by shared/public caches.
Performance Optimization
- Combine
mod_expireswithmod_deflate/mod_brotlicompression for maximum speed benefit — smaller files, cached longer. - Use long cache lifetimes (a year) for genuinely immutable assets, paired with hashed/versioned filenames.
- Use short or zero cache lifetimes for frequently changing content like HTML and API responses.
- Pair with a CDN for further performance gains — origin caching headers control how long the CDN itself holds content too.
Frequently Asked Questions
Q: What’s the difference between Expires and Cache-Control? A: Expires is an older HTTP/1.0-era header with an absolute date; Cache-Control: max-age is the modern HTTP/1.1 equivalent using a relative time. mod_expires sets both for broad compatibility.
Q: How long should I cache my static assets? A: For truly static assets with cache-busting filenames, a year is standard. For content that might change without a filename change, stick to a much shorter window (hours to a few weeks).
Q: Will enabling mod_expires break anything on my site? A: It shouldn’t, as long as you avoid long-caching dynamic content like HTML pages or API responses without a versioning strategy.
Q: Do I need mod_expires if I’m using a CDN? A: Yes — most CDNs respect (or at least reference) your origin’s cache headers to determine their own caching behavior, so it’s still worth configuring properly at the Apache level.
Q: Can I set different cache times for different folders? A: Yes, wrap your ExpiresByType/ExpiresDefault directives inside a <Directory> block scoped to that folder.
Summary and Key Takeaways
Enabling mod_expires is a small config change with an outsized impact on real-world page speed for repeat visitors. To recap:
- Enable the module and set sensible defaults with
ExpiresDefaultandExpiresByType. - Long-cache truly static assets (images, fonts, versioned CSS/JS); short-cache HTML and dynamic content.
- Pair with
mod_headersfor finer-grainedCache-Controlbehavior. - Use cache-busting filenames for any asset with a long cache lifetime.
- Verify with
curl -Iand browser dev tools, not just assumption.
It’s one of the easiest performance wins you can implement in a single afternoon.
