Add Brotli Compression to Your Nginx Docker Container
/ 11 min read
Updated:the problem
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:
#!/bin/bash# Script to deploy blog updates to the server automatically# Usage: ./deployBlog.sh or `bash deployBlog.sh`
# Get current branch nameCURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)echo "Deploying branch: $CURRENT_BRANCH"
# Push current branch to origingit push origin $CURRENT_BRANCHecho "Pushed $CURRENT_BRANCH to origin"
# Load SSH keys (git and docker server) for agent forwardingssh-add ~/.ssh/id_rsassh-add ~/.ssh/id_ed25519_ovh
# throw error if jq is not installed (needed for version checks)if ! command -v jq &> /dev/null; then echo "jq could not be found, install with apt/brew/etc." exit 1fi
# VERSION CHECKS# we're going to use nginx and alpine versions that correspond to# the most recently released `NginxMajor.Minor.Patch-AlpineMajor.Minor-slim` tag on docker-hub to ensure version consistency.# ex. 1.27.3-alpine3.20-slim# Extract the first matching tag and parse its components (most recent appears first)TAG_INFO=$(curl -s "https://hub.docker.com/v2/repositories/library/nginx/tags?page_size=100" | \ jq -r '[.results[].name | select(test("^[0-9]+\\.[0-9]+\\.[0-9]+-alpine[0-9]+\\.[0-9]+-slim$"))] | first')
if [ -n "$TAG_INFO" ]; then export NGINX_TAG="$TAG_INFO" # Extract NGINX_VERSION (everything before first hyphen) export NGINX_VERSION=$(echo "$TAG_INFO" | cut -d'-' -f1) # Extract ALPINE_VERSION (between "alpine" and "-slim") export ALPINE_VERSION=$(echo "$TAG_INFO" | sed -n 's/.*alpine\([0-9.]*\)-slim/\1/p')
echo "Found versions:" echo "NGINX_TAG=$NGINX_TAG" echo "NGINX_VERSION=$NGINX_VERSION" echo "ALPINE_VERSION=$ALPINE_VERSION"else echo "No matching tag found in nginx Docker Hub repo" exit 1fi
# Remote commands to execute on the server# replace MY_SERVER_USER and MY_SERVER_IP with your ownssh -A MY_SERVER_USER@MY_SERVER_IP << EOF # navigate to directory with monorepo cd ~/aaronjbecker.com
# fetch and checkout the current branch git fetch git checkout $CURRENT_BRANCH git pull
# Export the version variables so docker compose can access them export NGINX_TAG="$NGINX_TAG" export NGINX_VERSION="$NGINX_VERSION" export ALPINE_VERSION="$ALPINE_VERSION"
# rebuild the docker stack for the blog cd astro_blog docker compose up -d --build --remove-orphansEOF
echo "Deployment complete!"
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:
# this file assumes it will be called from `0_scripts/deployBlog.sh`,# which sets the following environment variables:# NGINX_TAG, NGINX_VERSION, ALPINE_VERSION
networks: traefik: external: true
services: ajb_blog: container_name: ajb_blog restart: unless-stopped build: context: . args: NGINX_TAG: ${NGINX_TAG:-1.27.3-alpine3.20-slim} NGINX_VERSION: ${NGINX_VERSION:-1.27.3} ALPINE_VERSION: ${ALPINE_VERSION:-3.20} networks: - traefik # ONLY uncomment this if you want to test the built docker container locally. # - default # ports: # - 8421:80 labels: - traefik.enable=true - "traefik.http.routers.ajb_blog.rule=Host(`aaronjbecker.com`) || Host(`www.aaronjbecker.com`)" - "traefik.http.routers.ajb_blog.entrypoints=websecure" - "traefik.http.routers.ajb_blog.tls.certResolver=myResolver" # redirect www to non-www (defined in traefik dynamic config) - "traefik.http.routers.ajb_blog.middlewares=redirect-www@file" volumes: # Nginx configuration with error pages, caching headers, and redirects - ./default.conf:/etc/nginx/conf.d/default.conf:ro
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:
# Nginx configuration for my Astro blog.# Handles error pages, caching headers, and redirects for URL changes.# Bind-mounted in docker-compose.yml.server { listen 80; server_name _; root /usr/share/nginx/html; index index.html;
# Cache Astro static assets for 1 year location /_astro/ { add_header Cache-Control "public, max-age=31536000, immutable"; }
# Default location block for serving static content location / { try_files $uri $uri/ =404; }
# custom 404 error page error_page 404 /404.html; location = /404.html { internal; } # Redirect rules for updated blog post URLs (OMITTED FOR BREVITY)...}
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:
# =========== GLOBAL ARGS ===========# Global ARGs can be used in any `FROM` but must be redeclared after each `FROM` stage (stages will use global values by default).ARG NGINX_TAG=1.27.3-alpine3.20-slimARG ALPINE_VERSION=3.20ARG NGINX_VERSION=1.27.3
# =========== ASTRO BUILD STAGE ===========# Base stage for building the Astro static filesFROM node:lts AS baseWORKDIR /appCOPY package*.json ./RUN npm install# run d2 install scriptRUN curl -fsSL https://d2lang.com/install.sh | sh -s --COPY . .RUN npm run build
# =========== NGINX BUILDER STAGE ===========# (compiles linked brotli modules)FROM alpine:${ALPINE_VERSION} AS nginx-builder# Redeclare the ARG we need in this stageARG NGINX_VERSIONRUN apk add --no-cache \ build-base \ pcre-dev \ zlib-dev \ openssl-dev \ wget \ git \ brotli-devWORKDIR /appRUN echo "Building nginx version: $NGINX_VERSION" \ && wget "https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz" \ && tar -zxf "nginx-${NGINX_VERSION}.tar.gz" \ && ln -s "nginx-${NGINX_VERSION}" nginx \ && git clone --recurse-submodules -j8 https://github.com/google/ngx_brotli \ && cd nginx \ && ./configure --with-compat --add-dynamic-module=../ngx_brotli \ && make modules
# =========== RUNTIME STAGE ===========# (assumes an alpine-based image)FROM nginx:${NGINX_TAG} AS runtime# install the brotli runtime librariesRUN apk add --no-cache brotli-libsCOPY --from=nginx-builder /app/nginx/objs/ngx_http_brotli_static_module.so /etc/nginx/modules/COPY --from=nginx-builder /app/nginx/objs/ngx_http_brotli_filter_module.so /etc/nginx/modules/# Configure nginx to use brotli modules# command appends to existing nginx.confRUN printf 'load_module modules/ngx_http_brotli_filter_module.so;\n\load_module modules/ngx_http_brotli_static_module.so;\n\%s' "$(cat /etc/nginx/nginx.conf)" > /etc/nginx/nginx.conf# Brotli settings (and enable gzip static compression as well)# command writes a new brotli.conf file to /etc/nginx/conf.d/RUN printf 'brotli on;\n\brotli_comp_level 6;\n\brotli_static on;\n\gzip on;\n\gzip_static on;\n\brotli_types application/atom+xml application/javascript application/json application/rss+xml\n\ application/vnd.ms-fontobject application/x-font-opentype application/x-font-truetype\n\ application/x-font-ttf application/x-javascript application/xhtml+xml application/xml\n\ font/eot font/opentype font/otf font/truetype image/svg+xml image/vnd.microsoft.icon\n\ image/x-icon image/x-win-bitmap text/css text/javascript text/plain text/xml;\n' > /etc/nginx/conf.d/brotli.conf# Copy Astro built files from base stageCOPY --from=base ./app/dist /usr/share/nginx/htmlEXPOSE 80
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.