"Serving precompressed static sites using NGINX to save disk space"

29 aug 2019

I had this idea to build my next ~~app~~ website as a completely static site, including all user specific content. So no C# ASP / Flask or React, just static files that get regenerated every so often with a cron job. The main advantage would be an extremely simple server with nearly instant page loads, and a disadvantage would be more disk usage. It definitely needs some form of authentication, so I still would have to write a bit of server code. But for a proof of concept, I tried to get nginx to serve precompressed files from disk, as most traffic is gzip compressed anyway. It's quite simple:

NGINX with gzip_static and gunzip modules

To serve gzip-precompressed static file, nginx needs the ngx_http_gzip_static_module For backwards compatibility with clients that do not support gzip compression, the ngx_http_gunzip_module module is also needed. These modules have to be included at compile time with ./configure flags. Luckily, these come with the the prebuilt nginx package that comes with the Ubuntu 18.04 I use. You can check this calling nginx -V:

$ nginx -V
nginx version: nginx/1.14.0 (Ubuntu)
built with OpenSSL 1.1.1  11 Sep 2018
TLS SNI support enabled
configure arguments: [...] --with-http_gunzip_module --with-http_gzip_static_module [...]

When these do not show up, you have to recompile nginx with the modules enabled.

Minimal Static Website with Nginx

This step is optional, just to get you on the same page, in a way which allows for experimenting with the nginx configuration without interfering with an existing setup. It eases testing a lot, but if you already have a nginx instance or static website running, its not needed.

So, lets build a minimal static website using jekyll:

$ jekyll new static-site
[...]
$ (cd static-site; jekyll build)
[...]
$ (cd static-site/_site; find -type f)
./about/index.html
./js/respond.js
./js/html5shiv.js
./index.html
./jekyll/update/2019/08/29/welcome-to-jekyll.html
./css/main.css
./feed.xml

$ (cd static-site/_site; find . -type f -exec du --apparent-size -ah {} +)
7,3K    ./about/index.html
10K     ./js/respond.js
10K     ./js/html5shiv.js
5,1K    ./index.html
6,7K    ./jekyll/update/2019/08/29/welcome-to-jekyll.html
8,7K    ./css/main.css
3,5K    ./feed.xml

Now, write a minimal custom nginx config. This way nginx can run as a regular user. Make sure to update the root path to point to the jekyll build target.

# file custom-nginx.conf
error_log /tmp/nginx.error.log;
daemon off;
pid /tmp/nginx.pid;
events { }
http {
    access_log /tmp/nginx.access.log;
    include    /etc/nginx/mime.types;
    server {
        listen 8080 default_server;
        listen [::]:8080 default_server;
        root /home/llandsmeer/static-site/_site;
        index index.html;
        server_name _;
        location / {
                try_files $uri $uri/ =404;
        }
    }
}

And run it as a regular user. Note that you need to specify an absolute path to the config file. The default jekyll template should show up at localhost:8080.

$ nginx -c $(readlink -f nginx.conf)

With this setup, we can continue to forcing all assets to be pre gzip-compressed.

Precompression

So lets precompress the static site. At first, I tried gzip -r . (and also gzip -r / in an accident, which was not so much fun...). That did not work, as nginx needs the original filename to be present as well as the gzipped variant. As the gzipped version is always used, the file with the original filename can be empty.

$ cd static-site/_site
$ for file in $(find . -type f)
do
    gzip -v "$file"
    touch -r "$file".gz "$file"
done
./about/index.html:      72.1% -- replaced with ./about/index.html.gz
./js/respond.js:         62.0% -- replaced with ./js/respond.js.gz
./js/html5shiv.js:       70.1% -- replaced with ./js/html5shiv.js.gz
./index.html:    61.7% -- replaced with ./index.html.gz
./jekyll/update/2019/08/29/welcome-to-jekyll.html:       59.8% -- replaced with ./jekyll/update/2019/08/29/welcome-to-jekyll.html.gz
./css/main.css:  77.5% -- replaced with ./css/main.css.gz
./feed.xml:      64.1% -- replaced with ./feed.xml.gz
$ cd ../..

Navigating to localhost:8080 will give you a blank page now. To allow nginx to serve precompressed files, the gzip_static option needs to be enabled. Setting it to on allows nginx to choose between a precompressed or normal version, but as there is no normal version, it is set to always. Then, for clients which do not support gzip compressed communication, the option gunzip is enabled which allows nginx to decompress a file before sending it over the wire.

So add these lines to the server directive in the nginx config:

gzip_static  always;
gzip_proxied expired no-cache no-store private auth;
gunzip       on;

Et voilà, a completely precompressed static website, served over nginx with backwards compatibility for clients that do not support compression.

Size Comparison

In this simple example, there was a nice reduction in size (altough for a website this size, precompression is definitely premature optimization). Note the distinction between disk usage and apparant file size. Directories and empty files take up space too!

Method Disk Usage Apparent
Normal 100K 87K
Gzipped 64K 53K

Next

There is also Zopfli, which performs better gzip compression but is a bit slower. However for files this small, the effect was not noticable in filesizes expressed in kilobytes. To perform precompression using zopfli, use this:

$ cd static-site/_site
$ for file in $(find . -type f)
do
    zopfli "$file"
    rm "$file"
    touch -r "$file".gz "$file"
done
$ cd ../..

Another possibility, of course, is to use a filesystem which has compression built in :).