---
title: "Browser caching with mod_expires"
date: "2018-02-05"
path: "/186-browser-caching-with-mod-expires.html"
section: "/ramblings"
description: "Setting Expires and Cache-Control headers in Apache 2.4 with mod_expires and mod_headers, and tidying up the old mod_deflate and ETag settings that usually come with them."
---

Every page speed report flags "leverage browser caching" sooner or later. It means the server isn't telling the browser how long it can keep images, stylesheets and scripts, so the browser asks for them again on every page. On Apache the fix is a few lines of `mod_expires`. I've been pasting the same block into sites since [the Magento days](/80-magento-apache-optimisation.html); this is that block brought up to date for Apache 2.4, with an explanation of each part.

## Enable the modules

On Debian or Ubuntu:

```bash
a2enmod expires headers deflate
systemctl restart apache2
```

On RHEL and CentOS all three are loaded by default.

## The mod_expires block

This can go in the virtual host or in the site's `.htaccess` (if `AllowOverride` permits it):

```apache
<IfModule mod_expires.c>
    ExpiresActive On
    ExpiresDefault "access plus 1 month"

    ExpiresByType text/html "access"
    ExpiresByType text/xml "access plus 60 minutes"
    ExpiresByType application/xml "access plus 60 minutes"
    ExpiresByType application/json "access"

    ExpiresByType text/css "access plus 1 year"
    ExpiresByType application/javascript "access plus 1 year"
    ExpiresByType text/javascript "access plus 1 year"

    ExpiresByType image/gif "access plus 1 month"
    ExpiresByType image/png "access plus 1 month"
    ExpiresByType image/jpeg "access plus 1 month"
    ExpiresByType image/svg+xml "access plus 1 month"
    ExpiresByType image/webp "access plus 1 month"
    ExpiresByType image/x-icon "access plus 1 year"

    ExpiresByType font/woff "access plus 1 year"
    ExpiresByType font/woff2 "access plus 1 year"
    ExpiresByType application/pdf "access plus 1 month"
    ExpiresByType video/mp4 "access plus 1 month"
</IfModule>
```

How it reads:

- `ExpiresActive On` switches the module on for this scope. Without it the rest does nothing.
- `ExpiresDefault` covers anything not listed by type.
- `ExpiresByType` matches the response's `Content-Type`. It has to match the MIME type Apache actually sends, so `image/jpg` (not a real type) never matches anything; use `image/jpeg`.
- `"access plus 1 month"` counts from when the browser fetched the file. The alternative is `"modification plus ..."`, which counts from the file's timestamp and only works for files on disk.
- `"access"` on its own means "expires now". HTML is set that way deliberately: pages change and you want visitors to see the change. Sitemaps and feeds get an hour.

For each response, `mod_expires` sends both an `Expires` date and a `Cache-Control: max-age=` in seconds. Browsers use `max-age` when both are present.

Older Apache builds don't know the font types. If `curl -I` on a `.woff2` file shows `application/octet-stream`, add the types first:

```apache
AddType font/woff .woff
AddType font/woff2 .woff2
```

## Long expiry needs versioned file names

A year-long expiry on `style.css` means a returning visitor won't see your new stylesheet for a year. That's only safe if the file name or URL changes whenever the content does. WordPress adds `?ver=` to enqueued scripts and styles, and Magento 2 puts a version number in the static path when static file signing is on. If your site does neither, keep CSS and JavaScript to a week or a month.

## mod_headers for Cache-Control

The blocks I used to use added this as well:

```apache
<IfModule mod_headers.c>
    <FilesMatch "\.(ico|pdf|jpe?g|png|gif|svg|webp|js|css|woff2?)$">
        Header set Cache-Control "max-age=2629743, public"
    </FilesMatch>
</IfModule>
```

2629743 seconds is about a month. With the `mod_expires` block above in place this mostly duplicates it, and `Header set` overrides whatever `mod_expires` worked out, so having both means two places disagree. The useful part is `public`, which tells shared caches (a CDN or Varnish) they may store the file. If you want that, append it rather than replacing the whole header:

```apache
<IfModule mod_headers.c>
    <FilesMatch "\.(ico|pdf|jpe?g|png|gif|svg|webp|js|css|woff2?)$">
        Header append Cache-Control "public"
    </FilesMatch>
</IfModule>
```

## ETags

Many optimisation snippets include:

```apache
FileETag None
```

On Apache 2.2 the default ETag included the file's inode, so the same file served from two servers behind a load balancer had two different ETags and browsers re-downloaded it. Apache 2.4 defaults to `MTime Size`, which is consistent across servers as long as deployments preserve timestamps. On a single server, leave ETags on; they let the browser revalidate a file with a cheap 304 once its expiry has passed. On several servers, either keep timestamps in sync or turn them off.

## Compression, without the 2008 browser sniffing

The other block that always came with this was `mod_deflate` with a set of `BrowserMatch` lines for Netscape 4 and old Internet Explorer, plus `Header append Vary User-Agent`. Those browsers are gone, and `Vary: User-Agent` tells every cache in the path to store a separate copy for each browser version, which wrecks hit rates on a CDN or Varnish. All that's needed now is:

```apache
<IfModule mod_deflate.c>
    AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css
    AddOutputFilterByType DEFLATE text/javascript application/javascript application/json
    AddOutputFilterByType DEFLATE application/xml image/svg+xml
</IfModule>
```

`mod_deflate` adds `Vary: Accept-Encoding` itself. Leave JPEG, PNG and other already-compressed formats out of the list; compressing them again costs CPU and saves nothing.

## Checking it

```bash
curl -sI https://www.example.com/css/style.css | grep -iE 'cache-control|expires|etag|vary'
curl -sI -H 'Accept-Encoding: gzip' https://www.example.com/ | grep -i content-encoding
```

The first should show a `max-age` matching what you set for CSS. The second should show `gzip`. If nothing changes, check the module is loaded (`apachectl -M | grep expires`) and that `.htaccess` files are allowed to set it.
