In a previous post, we looked at how to minify, compress, and containerize a static Astro site. As it turns out, the runtime container I used, fholzer/nginx-brotli:latest , has a critical security vulnerability, and the nginx version it uses is about a year old. In this post we’ll walk through adding brotli compression to an official nginx docker container and show how we can automate version checking to ensure we always deploy up-to-date containers.
why build your own container for nginx + brotli?
The official nginx open source images don’t include brotli support because it’s part of their paid nginx plus offering, which also means they have no incentive to make installing brotli easy for users of the free version (if anything, the opposite).
When I last checked, none of the existing nginx + brotli containers on Docker Hub are consistently updated for the latest nginx release. Many also include capabilities that aren’t relevant for me, like GeoIP or SSL/TLS termination (which I handle in other containers if at all). This Dockerfile by KiweeEu comes closest, but it uses a more bloated ubuntu base image. And while it’s more current than its peers, KiweeEu’s Dockerfile was still 2 nginx versions behind at the time of this writing.
Google provides a brotli module for nginx that can be loaded into an official nginx container, but it has to be built from source and linked against the runtime nginx version. Moreover, the linux distribution used to compile the module needs to match the runtime container. Docker provides us with a mechanism to not only isolate the build environment, but also ensure that versions are consistent, and it keeps the build process portable too.
updating on deployment
I deploy this blog with Docker, copying the output from a node-based Astro builder stage into an nginx container that serves the built static files. My setup already includes a bash script, a docker compose file, and a Dockerfile, so the version-checking logic that I added comfortably hooked into my existing deployment procedure.
As outlined in the diagram above, we’ll be using a bash script to check DockerHub for a new nginx release, and then using the tag discovered to align the nginx and alpine linux versions between a build stage that compiles brotli and the final runtime stage.
I’m assuming that I’ll be updating this blog frequently enough that I won’t need to update the nginx infrastructure between content updates. Because all of the tools I’m using here are standard and portable, you should be able to adapt them to your needs. I trigger deployment manually by running a script, but you could run the build script on a CI/CD server, for instance, or use a cron job to check for new nginx releases while serving your content from a bind-mounted volume.
step 1: deployment script
I use SSH agent forwarding to connect to my server and authenticate with GitHub, which allows me to use git to transfer content from my dev machine. Here’s a full copy of my current deployment script, highlighting the added version-checking logic:
step 2: docker compose
Docker Compose is my go-to deployment tool. Compose plays particularly well with Traefik, which I use to manage routing and TLS termination. This is my complete docker-compose.yml file:
For completeness, here’s my default.conf nginx configuration file, which lives in the astro_blog directory and is bind-mounted into the container at runtime:
step 3: Dockerfile
This Dockerfile consists of 3 stages:
Astro build stage: compiles the Astro site into static files
Nginx builder stage: compiles the brotli module into a shared object file and links it against the target nginx version
Runtime stage: uses an alpine-based image to load the compiled brotli module and serve the built static files with nginx
Depending on your requirements, you may not need all 3 stages. For instance, if you already have a pre-built static site, you could skip the Astro build stage and just use the runtime stage to serve your static files with nginx, bind-mounting your own files into /usr/share/nginx/html.
Here’s a full copy of my Dockerfile:
conclusion
Landing on a working solution that ensures that your static content is always hosted on an up-to-date nginx container with brotli compression took a fair bit of trial and error; this post mostly reflects my desire to share the steps that worked for me in case anyone else wants to try something similar. The same general process could be adapted to other nginx modules that require compilation, like Google’s ngx_pagespeed or zstd compression.
While AI coding assistance from Claude1 sped the process along, an earlier attempt to rely on Claude for the overall architecture ended up being a waste of time. When tackling such a niche problem, Claude can overlook important details, like how build caching works in a multi-stage Dockerfile: setting up version checking as a build stage doesn’t work because the Dockerfile won’t re-run that version checking stage unless an external input changes. I still wouldn’t have undertaken this if I had to write it all by hand.