Websites & apps

Set browser caching in Apache with mod_expires and headers

Tell browsers how long to keep your files. Use mod_expires and Cache-Control headers in Apache to cache versioned assets for a year and keep HTML fresh.

BeginnerLinux server4 min read

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 sudo access.
  • 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.css or style.css?ver=6.5. WordPress and most build tools do this already.

How the headers work

  • Cache-Control: max-age=31536000 tells the browser the file is fresh for that many seconds (here, one year).
  • Expires gives a fixed date. It is the older header; when both are present, Cache-Control wins.
  • Cache-Control: no-cache does not mean "do not cache". It means "check with the server before using the copy". Apache answers such checks with a quick 304 Not Modified if nothing changed.
  • immutable tells 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

Bash
sudo a2enmod expires headers
sudo systemctl restart apache2

AlmaLinux and Rocky Linux

Both modules are loaded by default. Confirm with:

Bash
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.

Apache
<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:

Bash
sudo a2enconf caching

Warning: 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:

Apache
<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

Bash
sudo apachectl configtest
sudo systemctl reload apache2    # Ubuntu and Debian
sudo systemctl reload httpd      # AlmaLinux and Rocky

Check it works

Check the headers of a stylesheet:

Bash
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:

Bash
curl -sI https://example.com/ | grep -i cache-control

It 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 Expires or Cache-Control header at all: ExpiresActive On is missing, the module is not enabled, or the file's MIME type does not match any rule. Compare with the response's Content-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 Host header.
  • Invalid command 'ExpiresActive': mod_expires is not loaded. Enable it and restart.
  • Dynamic pages cached by mistake: remove php from any long-lived rule. Never give HTML a long lifetime.

Next steps

Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.