Pre-compress static assets with Brotli and Gzip

This is part 26 of my article series 25+ Tutorials on How to boost the performance of your WooCommerce store. Pre-compressing static assets reduces the CPU load of your server and avoids to compress static files upon each an every request. Here's how you can do that with Gzip, Brotli and Grunt.

There are many tutorials and How-to guides on how to enable compression for WordPress. Recently, I stumbled over Arne Blankerts excellent talk at the 2019 International PHP Conference on YouTube and his suggestion makes a lot of sense. In a world where code is deployed in a automated fashion, it doesn’t make sense to re-compress static assets upon request. Instead, static assets should be compressed upon deploy, for example using our build tools such as Gulp or Grunt. Then we tell Nginx to deliver that pre-compressed file.

That said, it is totally fine, to have the server compress dynamic content such as HTML pages being returned from PHP, but for static assets such as CSS and JS files that doesn’t make sense. That would be a waste of CPU resources and might slow down page load times.

The only time these static files usually change is when we deploy our code to the server.

Both the Brotli and the Gzip modules for Nginx come with an instruction which basically tells the server: “If you find a pre-compressed file, send it to the browser”.

As seen on Arne’s slides on page 11:

/etc/nginx/sites-available/mywebiste.conf
location / { gzip_static on; brotli_static on; try_files $uri $uri/ @php; } location @php { gzip on; brotli on; fastcgi_pass unix:/var/run/php-fpm.sock; include fastcgi_params; fastcgi_param SCRIPT_FILENAME /var/www/php/index.php; }

Let’s say we have a file called /assets/styles.css, our main stylesheet. Once the browser tries to load it, gzip_static on; tells Nginx to look for a file called /assets/styles.css.gz and if present it will simply send it to the visitor. If Nginx can’t find neither the pre-compressed nor the static resource, well then it’s probably something dynamic, so the request is passed along to PHP, which is compressed dynamically as indicated by gzip on;. The precedence of gzip over brotli or vice versa is up to the Browsers’ Accept-Encoding header.

The easiest way: Github Actions

A very easy and convenient way of doing this, is by adding a step inside a Github Actions Workflow (more on this in part 4):

.github/workflows/deploy.yaml
# pre-compress static assets with gzip and brotli - name: Compress Files uses: stefh/ghaction-CompressFiles@v1 with: path: ./wordpress depth: 5 extensions: '.css,.html,.js,.map,.ttf,.otf,.eot,.svg,.woff,.woff2,.xml,.txt,.md' tools: 'brotli,gzip'

Compression in your build process

If you don’t or don’t want to use Github Actions, you could also use grunt – or npm compress.

Here an example using Grunt.

module.exports = function(grunt) {
    grunt.initConfig({
        pkg: grunt.file.readJSON('package.json'),
        compress: {
            css_gz: {
                options: {
                  mode: 'gzip',
                  level: 6
                },
                expand: true,
                cwd: 'wordpress/',
                dest: 'wordpress/',
                extDot: 'last',
                src: ['**/*.css'],
                ext: '.css.gz'
            },
            css_br: {
                options: {
                  mode: 'brotli',
                  brotli: {
                    mode: 1,
                    level: 9
                  }
                },
                expand: true,
                cwd: 'wordpress/',
                dest: 'wordpress/',
                extDot: 'last',
                src: ['**/*.css'],
                ext: '.css.br'
            },
            ...
        }
    });
    grunt.loadNpmTasks('grunt-contrib-compress');
    grunt.registerTask('default', [
        'compress:css_gz',
        'compress:css_br',
        ...
    ]);

This example includes two example sub task called css_gz and css_br, of course you might want to define more, for all static assets of your project. In my opinion one should provide both formats, since the decision which format is being used is done by the browser. The more formats you provide, the safer it is that the browser will receive a compressed version.

Teaching Nginx to speak Brotli

In order for Nginx to deliver Brotli-compressed files to any browser that can handle them, we need to install the Brotli Module for Nginx. This is how you do that manually:

cd ~
wget https://nginx.org/download/nginx-1.19.9.tar.gz
tar -xzf nginx-1.19.9.tar.gz
git clone https://github.com/google/ngx_brotli.git ~/ngx_brotli
cd ~/nginx-1.19.9
./configure --with-compat --add-dynamic-module=../ngx_brotli
make
cp ~/nginx-1.19.9/objs/ngx_http_brotli_filter_module.so /usr/share/nginx/modules
cp ~/nginx-1.19.9/objs/ngx_http_brotli_static_module.so /usr/share/nginx/modules

It’s important to download the same version of Nginx that you are currently running. So adjust the version number accordingly.

Install Brotli with Ansible

If you’re using Ansible to provision your servers, the folling task does the job, too.

### Brotli Install
- name: Gather the apt package facts
  package_facts:
    manager: auto

- name: Get Nginx version and set variable
  set_fact:
    nginx_version: "{{ ansible_facts.packages['nginx'][0].version | regex_search('^[0-9.]*') }}"

- name: Check if module is present (and if so skip nginx compiling)
  stat:
    path: /usr/lib/nginx/modules/ngx_http_brotli_filter_module.so
  become: yes
  register: module_exists
  
- name: Download and unarchive Nginx source code of version {{ nginx_version }}
  unarchive:
    src: https://nginx.org/download/nginx-{{ nginx_version }}.tar.gz
    dest: ~/
    remote_src: yes
  when: not module_exists.stat.exists

- name: Git clone Brotli module (google/ngx_brotli)
  git:
    repo: https://github.com/google/ngx_brotli.git
    dest: ~/ngx_brotli
  when: not module_exists.stat.exists

- name: Configure Brotli module
  command:
    chdir: ~/nginx-{{ nginx_version }}
    cmd: ./configure --with-compat --add-dynamic-module=../ngx_brotli
  when: not module_exists.stat.exists

- name: Make Brotli module (Dynamically loaded)
  make:
    chdir: ~/nginx-{{ nginx_version }}
    target: modules
  when: not module_exists.stat.exists

- name: Copy compiled Brotli module to /usr/share/nginx/modules
  copy: 
    remote_src: yes
    src: "{{ item.src }}"
    dest: "{{ item.dest }}"
    owner: "{{ ansible_user }}"
    group: "{{ ansible_user }}"
    mode: 0644
  with_items:
    - { src: "~/nginx-{{ nginx_version }}/objs/ngx_http_brotli_filter_module.so", dest: "/usr/share/nginx/modules" }
    - { src: "~/nginx-{{ nginx_version }}/objs/ngx_http_brotli_static_module.so", dest: "/usr/share/nginx/modules" }
  when: not module_exists.stat.exists

This is my suggested list of filetypes for pre-compression: css, js, map, ttf, otf, eot, svg, woff, woff2, xml, txt and md.

Validate it works

One option is to look at the reponse headers of network requests in your browsers’ developer toolbar.

Another way is to run the following command on your Webserver:

ps ax | grep nginx
strace -p <process-id> 2>&amp;1 | grep gz
strace -p <process-id> 2>&amp;1 | grep br

Replace <process-id> with the actual process ID of (one of your) Nginx Workers.

Next, we use strace to monitor internals and filter the output that matches strings 'gz' or 'br':

Reload some random pages and you should see some output. If you don’t see any output, try with the process ID of the other worker. If you’re still not getting any output there might be an error or misconfiguration.

Also, find a simplified installation instruction in Mike MacCana’s excellent article about Brotli compression on Expedited Security’s Blog.