skip to content
Aaron Becker

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.

Diagram

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:

deployBlog.sh
#!/bin/bash
# Script to deploy blog updates to the server automatically
# Usage: ./deployBlog.sh or `bash deployBlog.sh`
# Get current branch name
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
echo "Deploying branch: $CURRENT_BRANCH"
# Push current branch to origin
git push origin $CURRENT_BRANCH
echo "Pushed $CURRENT_BRANCH to origin"
# Load SSH keys (git and docker server) for agent forwarding
ssh-add ~/.ssh/id_rsa
ssh-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 1
fi
# 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 1
fi
# Remote commands to execute on the server
# replace MY_SERVER_USER and MY_SERVER_IP with your own
ssh -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-orphans
EOF
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:

astro_blog/docker-compose.yml
# 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:

astro_blog/default.conf
# 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:

  1. Astro build stage: compiles the Astro site into static files
  2. Nginx builder stage: compiles the brotli module into a shared object file and links it against the target nginx version
  3. 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:

astro_blog/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-slim
ARG ALPINE_VERSION=3.20
ARG NGINX_VERSION=1.27.3
# =========== ASTRO BUILD STAGE ===========
# Base stage for building the Astro static files
FROM node:lts AS base
WORKDIR /app
COPY package*.json ./
RUN npm install
# run d2 install script
RUN 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 stage
ARG NGINX_VERSION
RUN apk add --no-cache \
build-base \
pcre-dev \
zlib-dev \
openssl-dev \
wget \
git \
brotli-dev
WORKDIR /app
RUN 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 libraries
RUN apk add --no-cache brotli-libs
COPY --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.conf
RUN 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 stage
COPY --from=base ./app/dist /usr/share/nginx/html
EXPOSE 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.

Footnotes

  1. I’m using Claude 3.5 Sonnet via Cursor.