Google's PageSpeed modules, mod_pagespeed for Apache and ngx_pagespeed for Nginx, rewrote pages on the fly to compress, minify and convert assets. The project was donated to the Apache Software Foundation and later retired: it receives no updates, has no packages for Ubuntu 24.04 and building it against a current Nginx is not supported. This tutorial explains what the modules did and shows how to get the same results on Ubuntu 24.04 with maintained tools: Brotli and gzip compression, long-lived cache headers, minification at build time and WebP images, for both Nginx and Apache.

What PageSpeed did and what replaces it

Most PageSpeed filters map to a feature that the web server or your build process now handles better, because the work is done once instead of on every request:

PageSpeed filterModern replacement
rewrite_css, rewrite_javascript (minify)Minify at build or deploy time with esbuild
combine_css, combine_javascriptHTTP/2 multiplexing, or bundling at build time
gzip handlinggzip plus Brotli in the web server
extend_cacheCache-Control headers with fingerprinted file names
convert_jpeg_to_webp, recompress_imagesPre-generated WebP/AVIF served by Accept header
lazyload_imagesNative loading="lazy" attribute in HTML
defer_javascriptdefer attribute on <script> tags

Filters that rewrote HTML (defer_javascript, lazyload_images) are better done in your templates, where you know which scripts and images are critical.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS with either Nginx or Apache installed from the Ubuntu repositories and serving a site over HTTPS, for example a CubePath VPS.
  • A non-root user with sudo privileges.
  • A site whose static files live in a directory you control, referred to as /var/www/your_domain.

Steps 1 and 2 have separate Nginx and Apache sections. Follow the one for your server.

Step 1 - Enabling Brotli and gzip compression

Brotli compresses text assets 15 to 25 percent smaller than gzip, and every current browser supports it. Keep gzip enabled as a fallback for older clients.

Nginx

Ubuntu 24.04 ships the Brotli modules as packages. Install both: the filter compresses responses on the fly, and the static module serves pre-compressed .br files if they exist:

sudo apt update
sudo apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static

The packages load the modules automatically through /etc/nginx/modules-enabled/. Create a configuration file for compression that applies to every site:

sudo nano /etc/nginx/conf.d/compression.conf
brotli on;
brotli_comp_level 5;
brotli_static on;
brotli_types text/plain text/css text/xml application/javascript application/json application/xml image/svg+xml font/ttf application/vnd.ms-fontobject;

gzip on;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_static on;
gzip_types text/plain text/css text/xml application/javascript application/json application/xml image/svg+xml font/ttf application/vnd.ms-fontobject;

text/html is always compressed and must not be listed. Level 5 is a good balance between CPU and size for on-the-fly compression. The default /etc/nginx/nginx.conf on Ubuntu already contains gzip on;, and a directive repeated at the same level makes nginx -t fail. Open the main file and comment out that line:

sudo nano /etc/nginx/nginx.conf
        # gzip on;

Test the configuration and reload:

sudo nginx -t
sudo systemctl reload nginx

Apache

Apache 2.4 on Ubuntu includes mod_brotli and mod_deflate. Enable them with mod_filter, which provides the AddOutputFilterByType directive:

sudo a2enmod brotli deflate filter

Create a configuration snippet:

sudo nano /etc/apache2/conf-available/compression.conf
<IfModule mod_brotli.c>
    BrotliCompressionQuality 5
    AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css text/xml application/javascript application/json application/xml image/svg+xml
</IfModule>

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

When both filters apply, Apache uses Brotli for clients that accept it and gzip for the rest. Enable the snippet and reload:

sudo a2enconf compression
sudo apachectl configtest
sudo systemctl reload apache2

Verifying compression

Request a CSS file and ask for Brotli. Use -D - to print the headers of a normal GET request:

curl -s -o /dev/null -D - -H 'Accept-Encoding: br, gzip' https://your_domain/css/style.css | grep -i -E 'content-encoding|vary'
content-encoding: br
vary: Accept-Encoding

Repeat with -H 'Accept-Encoding: gzip' and the header should change to gzip.

Step 2 - Setting long cache headers for static assets

PageSpeed's extend_cache filter renamed assets with a content hash so they could be cached for a year. Modern build tools (Vite, webpack, esbuild) already produce fingerprinted names such as app.3f9c2a.js, so the server only needs to send the right headers.

Nginx

Open your site's server block:

sudo nano /etc/nginx/sites-available/your_domain

Add these locations inside the server block:

location ~* \.(?:css|js|mjs|woff2|svg|png|jpe?g|gif|webp|avif|ico)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    access_log off;
    try_files $uri =404;
}

location ~* \.html$ {
    add_header Cache-Control "no-cache";
}

expires 1y sets both Expires and Cache-Control: max-age, and immutable tells the browser not to revalidate the file at all. HTML gets no-cache, meaning the browser revalidates it on each visit, so a new deploy that references new asset names is picked up at once.

sudo nginx -t
sudo systemctl reload nginx

Apache

Enable the expires and headers modules:

sudo a2enmod expires headers

Add the following inside your site's <VirtualHost *:443> block in /etc/apache2/sites-available/your_domain-le-ssl.conf (or the file that holds your HTTPS virtual host):

sudo nano /etc/apache2/sites-available/your_domain-le-ssl.conf
<FilesMatch "\.(css|js|mjs|woff2|svg|png|jpe?g|gif|webp|avif|ico)$">
    ExpiresActive On
    ExpiresDefault "access plus 1 year"
    Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>

<FilesMatch "\.html$">
    Header set Cache-Control "no-cache"
</FilesMatch>
sudo apachectl configtest
sudo systemctl reload apache2

Verifying cache headers

curl -s -o /dev/null -D - https://your_domain/css/style.css | grep -i cache-control
cache-control: max-age=31536000
cache-control: public, immutable

Nginx sends two Cache-Control headers (one from expires, one from add_header), which browsers combine. Apache sends a single header.

Step 3 - Minifying CSS and JavaScript at deploy time

Instead of minifying on every request, minify once when you deploy. If your project already uses a bundler such as Vite or webpack, production builds are minified and you can skip this step. For plain static sites, esbuild minifies CSS and JavaScript in milliseconds.

Install Node.js and npm from the Ubuntu repositories:

sudo apt install nodejs npm

Minify a stylesheet and a script, writing the results next to the originals:

cd /var/www/your_domain
npx --yes esbuild css/style.css --minify --outfile=css/style.min.css
npx --yes esbuild js/app.js --minify --outfile=js/app.min.js

esbuild prints the output file and its size for each command. Compare it with the original using ls -lh css/.

Point your HTML at the .min files and add defer to scripts that do not need to run before the page renders, which replaces PageSpeed's defer_javascript:

<link rel="stylesheet" href="/css/style.min.css">
<script src="/js/app.min.js" defer></script>

Optionally, pre-compress the minified files at the highest level so the brotli_static and gzip_static directives from Step 1 serve them without spending CPU per request (Nginx only):

sudo apt install brotli
brotli --best --keep css/style.min.css js/app.min.js
gzip --best --keep css/style.min.css js/app.min.js

This creates .br and .gz files beside each asset. Nginx serves them automatically when the client accepts that encoding. Regenerate them on every deploy, because a stale .br file is served instead of the updated original.

Step 4 - Serving WebP images and lazy loading

Image conversion was PageSpeed's biggest win. Do it once with cwebp and let the server pick the format based on the browser's Accept header.

Install the WebP tools and convert your images, keeping the original extension in the new name (photo.jpg becomes photo.jpg.webp):

sudo apt install webp
cd /var/www/your_domain
find images -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' \) -exec sh -c 'cwebp -quiet -q 80 "$1" -o "$1.webp"' _ {} \;

On Nginx, add a map to the http context so the server knows whether the client accepts WebP:

sudo nano /etc/nginx/conf.d/webp.conf
map $http_accept $webp_suffix {
    default   "";
    "~*image/webp"  ".webp";
}

Then, in your site's server block, place this location before the generic static-asset location from Step 2 so it matches first:

location ~* \.(?:jpe?g|png)$ {
    add_header Vary Accept;
    expires 1y;
    try_files $uri$webp_suffix $uri =404;
}

Reload and check that a browser advertising WebP receives it:

sudo nginx -t && sudo systemctl reload nginx
curl -s -o /dev/null -D - -H 'Accept: image/webp,*/*' https://your_domain/images/photo.jpg | grep -i content-type
content-type: image/webp

For Apache, and for AVIF support and automated conversion of new uploads, see the image optimization pipeline guide, which covers this in depth.

Finally, replace PageSpeed's lazyload_images with the native attribute on images below the fold. Keep the first visible image (the hero or logo) without it so it loads immediately:

<img src="/images/photo.jpg" alt="Server rack" width="1200" height="800" loading="lazy">

Setting width and height lets the browser reserve space and avoids layout shifts.

Step 5 - Measuring the result

Run Lighthouse from Chrome DevTools (the Lighthouse tab) or on pagespeed.web.dev before and after these changes. Focus on Largest Contentful Paint and total transfer size. From the server you can compare transfer sizes directly:

curl -s -o /dev/null -w '%{size_download} bytes\n' https://your_domain/css/style.css
curl -s -o /dev/null -w '%{size_download} bytes\n' -H 'Accept-Encoding: br' https://your_domain/css/style.css
48213 bytes
9120 bytes

Troubleshooting

nginx -t reports unknown directive "brotli". The module packages are not installed or their files in /etc/nginx/modules-enabled/ were removed. Reinstall libnginx-mod-http-brotli-filter.

nginx -t reports "gzip" directive is duplicate. The gzip on; line in /etc/nginx/nginx.conf is still active. Comment it out as shown in Step 1.

Responses are not compressed although the configuration is correct. Check the file's Content-Type with curl -I. If it is not in brotli_types or AddOutputFilterByType, it is skipped. Responses behind a CDN may also be recompressed or decompressed by the CDN.

Visitors see old CSS after a deploy. A file with a one-year immutable cache was changed without renaming it. Use fingerprinted file names or add a version query string such as style.min.css?v=2 in the HTML.

Conclusion

You replaced every useful PageSpeed filter with a maintained alternative: Brotli and gzip compression in the server, long-lived cache headers, minification and pre-compression at deploy time, WebP images chosen by Accept header and native lazy loading. These changes run once per deploy instead of on every request, so they cost less CPU than PageSpeed did. Next, consider enabling HTTP/3, adding AVIF images and putting a CDN in front of your static assets.