Caching and compression in Apache: gzip, Brotli, Expires, mod_cache
Three settings do most of the work: compress text before sending it (mod_deflate for gzip, mod_brotli for Brotli), tell browsers how long they may keep each file (mod_expires and mod_headers), and, for slow pages or proxied apps, keep a copy on the server (mod_cache with mod_cache_disk). This page gives working Apache httpd 2.4 configuration for each, plus the curl commands that prove it works.
Three different jobs
- Compression
- The server shrinks a response on the fly and labels it
Content-Encoding: gziporbr. It saves bandwidth on every request, but costs some CPU each time. - Browser caching
- The server adds
Cache-Control: max-age=…(and the olderExpires) so the browser reuses its copy without asking again. When the time runs out, the browser asks "has it changed?" and gets a short304 Not Modifiedif it has not. - Server-side caching
- mod_cache stores whole responses on the server and serves them without running PHP or contacting the backend again. Useful in front of slow applications; rarely needed for plain static files.
Turn on gzip compression
mod_deflate provides the DEFLATE filter. Despite the name it only produces the gzip encoding. On Debian and Ubuntu it is enabled by default and /etc/apache2/mods-available/deflate.conf already compresses HTML, CSS, JavaScript and XML. On RHEL, AlmaLinux, Rocky and Fedora the module is loaded (in /etc/httpd/conf.modules.d/00-base.conf) but nothing is compressed until you add a rule.
Compress text formats only. JPEG, PNG, WebP, video and zip files are already compressed; running them through gzip burns CPU for no gain.
Add Brotli, in the right order
mod_brotli (httpd 2.4.26 and later) provides the BROTLI_COMPRESS filter. Enable it with sudo a2enmod brotli on Debian/Ubuntu; on RHEL and Fedora it ships in the main httpd package and is loaded by 00-brotli.conf.
Order matters. With AddOutputFilterByType, filters run in the order they are listed, and each compression filter skips a response that already has a Content-Encoding. So list Brotli first: clients that accept br get Brotli, the rest fall through to gzip. If DEFLATE runs first, a browser that sends Accept-Encoding: br, gzip always gets gzip and Brotli is never used.
# Debian/Ubuntu: /etc/apache2/conf-available/compression.conf
# RHEL/Fedora: /etc/httpd/conf.d/compression.conf
<IfModule mod_filter.c>
<IfModule mod_brotli.c>
AddOutputFilterByType BROTLI_COMPRESS;DEFLATE text/html text/plain text/css text/xml text/javascript application/javascript application/json application/xml image/svg+xml
</IfModule>
<IfModule !mod_brotli.c>
AddOutputFilterByType DEFLATE text/html text/plain text/css text/xml text/javascript application/javascript application/json application/xml image/svg+xml
</IfModule>
</IfModule>
Debian and Ubuntu catch. The stock deflate.conf registers DEFLATE for the same types at server level, and it is read before your file, so gzip would still win. Comment out its AddOutputFilterByType lines (keep the file and the module), then enable your file: sudo a2enconf compression.
The defaults are sensible: zlib's default level for gzip and BrotliCompressionQuality 5 for Brotli (range 0–11). Higher Brotli settings are very slow and only make sense for files you compress once in advance. Then test and reload:
sudo apachectl configtest
sudo systemctl reload apache2 # RHEL/Fedora: sudo systemctl reload httpd
Compression and TLS. The httpd documentation warns that some web applications that put secrets (such as CSRF tokens) in compressed HTTPS responses are exposed to the "BREACH" family of attacks. Static files are not affected; for sensitive dynamic pages, check your application's guidance.
Check compression with curl
Send the same header a browser would send and look at the response headers. Use a normal GET (-o /dev/null -D -) rather than -I, so you test the same path as a real page view:
curl -s -o /dev/null -D - -H 'Accept-Encoding: br, gzip' https://www.example.com/ | grep -i -E 'content-encoding|vary'
Content-Encoding: br
Vary: Accept-Encoding
Compare sizes with -w '%{size_download}\n' and different Accept-Encoding values (br, gzip, identity). The Vary: Accept-Encoding header is added by both modules so that proxies and CDNs keep the compressed and uncompressed versions apart. Do not remove it.
Browser caching with mod_expires
mod_expires sets both Expires and Cache-Control: max-age from the file's MIME type. Enable it with sudo a2enmod expires headers on Debian/Ubuntu (both are loaded by default on RHEL). A practical policy:
- HTML: short (minutes), so visitors see edits soon.
- CSS, JavaScript and fonts whose file name contains a version or hash (
app.3f9a1c.css): one year, markedimmutable. When the file changes, its name changes. - Images: a month is a reasonable middle ground.
<IfModule mod_expires.c>
ExpiresActive On
ExpiresDefault "access plus 1 hour"
ExpiresByType text/html "access plus 5 minutes"
ExpiresByType text/css "access plus 1 year"
ExpiresByType text/javascript "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
ExpiresByType image/png "access plus 1 month"
ExpiresByType image/jpeg "access plus 1 month"
ExpiresByType image/webp "access plus 1 month"
ExpiresByType image/svg+xml "access plus 1 month"
</IfModule>
<IfModule mod_headers.c>
# Fingerprinted assets such as app.3f9a1c.css never change under the same name
<FilesMatch "\.[0-9a-f]{6,}\.(css|js|woff2)$">
Header merge Cache-Control "public, immutable"
</FilesMatch>
</IfModule>
Check a fingerprinted file:
curl -s -o /dev/null -D - https://www.example.com/assets/app.3f9a1c.css | grep -i -E 'cache-control|expires'
Cache-Control: max-age=31536000, public, immutable
Expires: Thu, 04 Nov 2027 07:09:46 GMT
A year is 365 × 24 × 3,600 = 31,536,000 seconds. When both headers are present, HTTP caches use max-age and ignore Expires (RFC 9111). immutable (RFC 8246) tells browsers not to revalidate the file during that year, even on reload. Use it only for file names that change with their content; a one-year lifetime on style.css means visitors keep an old stylesheet after you edit it.
For pages that must always be checked (account pages, carts), send Header set Cache-Control "no-cache" (store, but revalidate every time) or "no-store" (never store). mod_expires does not touch responses that already carry an Expires header, for example from a proxied backend.
ETags and 304 responses
For static files Apache sends an ETag built from the file's modification time and size (FileETag MTime Size, the 2.4 default) and a Last-Modified date. When a cached copy expires, the browser sends If-None-Match or If-Modified-Since, and Apache answers 304 with no body if nothing changed. Test it:
curl -s -o /dev/null -w '%{http_code}\n' -H 'If-None-Match: "2134-65f6e0e5e27bf"' https://www.example.com/assets/app.3f9a1c.css
304
Compressed responses are different. By default both modules add a suffix to the ETag ("8643-65f6e0e5e27bf-gzip", …-br), and the httpd documentation notes that this prevents 304 answers for compressed content: the browser downloads the full page again. Since httpd 2.4.58 you can drop the ETag on compressed responses so that revalidation falls back to Last-Modified, which does produce 304s:
DeflateAlterETag Remove
BrotliAlterETag Remove
BrotliAlterETag has existed since mod_brotli arrived, but DeflateAlterETag needs 2.4.58 or later: run apachectl -v first, because older versions reject it as a syntax error. If several servers sit behind a load balancer, keep the default FileETag MTime Size (no inode number), and make sure deployments preserve file modification times, or each server will hand out different ETags for the same file.
Server-side cache with mod_cache_disk
mod_cache follows HTTP caching rules: it stores only responses that are allowed to be cached (normally GET requests with a 200 status and a validator or lifetime, and not marked private or no-store), and serves them until they go stale. Enable it with sudo a2enmod cache cache_disk on Debian/Ubuntu; both modules are loaded by default on RHEL.
<VirtualHost *:443>
ServerName app.example.com
# ... TLS and ProxyPass settings ...
CacheQuickHandler off
# RHEL/Fedora: CacheRoot "/var/cache/httpd/proxy"
CacheRoot "/var/cache/apache2/mod_cache_disk"
CacheEnable disk "/"
CacheDisable "/account"
CacheDirLevels 2
CacheDirLength 1
CacheIgnoreHeaders Set-Cookie
CacheLock on
CacheHeader on
</VirtualHost>
CacheQuickHandler offruns the cache after authentication and authorization. The default (on) is faster but skips those checks, so cached content could reach users who should be refused.CacheIgnoreHeaders Set-Cookiestops one visitor's cookie being stored with a page and replayed to others.CacheLock onstops a rush of requests all hitting the backend when one popular entry expires.CacheHeader onaddsX-Cache: HIT,MISSorREVALIDATE, so you can test. Turn it off afterwards.
curl -s -o /dev/null -D - https://app.example.com/ | grep -i x-cache
X-Cache: MISS from app.example.com
curl -s -o /dev/null -D - https://app.example.com/ | grep -i x-cache
X-Cache: HIT from app.example.com
mod_cache_disk never deletes anything by itself. Run htcacheclean to keep the cache to a size limit. Debian ships it as the apache-htcacheclean service (settings in /etc/default/apache-htcacheclean, default limit 300M); RHEL and Fedora ship htcacheclean.service (settings in /etc/sysconfig/htcacheclean, default limit 100M). Make sure its path matches your CacheRoot:
sudo systemctl enable --now apache-htcacheclean # RHEL/Fedora: htcacheclean
Mistakes to avoid
- Listing DEFLATE before BROTLI_COMPRESS, or keeping Debian's deflate.conf lines next to your own: Brotli never gets used.
- Compressing images, video or archives: no gain, extra CPU.
- Year-long lifetimes on file names that do not change.
- Caching pages that differ per user: mark them
Cache-Control: privatein the application, or exclude them withCacheDisable. - Putting these rules in .htaccess when you control the main configuration: it works, but every request pays to read the file (see .htaccess files).
- Forgetting htcacheclean until the disk fills up.
Next: tune the server itself in performance tuning, or put a cache in front of an application with the reverse proxy guide.
Sources
- mod_deflate, Apache HTTP Server 2.4 documentation
- mod_brotli, Apache HTTP Server 2.4 documentation
- mod_filter (AddOutputFilterByType), Apache HTTP Server 2.4 documentation
- mod_expires, Apache HTTP Server 2.4 documentation
- mod_headers, Apache HTTP Server 2.4 documentation
- FileETag directive, Apache HTTP Server 2.4 documentation
- Caching Guide, Apache HTTP Server 2.4 documentation
- mod_cache, Apache HTTP Server 2.4 documentation
- mod_cache_disk, Apache HTTP Server 2.4 documentation
- htcacheclean, Apache HTTP Server 2.4 documentation
- mod_deflate.c source (skips responses that are already encoded), Apache httpd 2.4.x branch
- Debian apache2 packaging (deflate.conf, cache_disk.conf, apache-htcacheclean), sources.debian.org
- Fedora httpd package (00-base.conf, 00-brotli.conf, htcacheclean.service), src.fedoraproject.org
- RFC 9111 HTTP Caching, IETF
- RFC 8246 HTTP Immutable Responses, IETF