Caching and compression in Apache: gzip, Brotli, Expires, mod_cache

Updated November 2026

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: gzip or br. It saves bandwidth on every request, but costs some CPU each time.
Browser caching
The server adds Cache-Control: max-age=… (and the older Expires) so the browser reuses its copy without asking again. When the time runs out, the browser asks "has it changed?" and gets a short 304 Not Modified if 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:

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

Next: tune the server itself in performance tuning, or put a cache in front of an application with the reverse proxy guide.

Sources