Browser caching with mod_expires
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; 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:
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):
<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 Onswitches the module on for this scope. Without it the rest does nothing.ExpiresDefaultcovers anything not listed by type.ExpiresByTypematches the response'sContent-Type. It has to match the MIME type Apache actually sends, soimage/jpg(not a real type) never matches anything; useimage/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:
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:
<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:
<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:
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:
<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
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.