skip to content
Aaron Becker

Build Your Own Always-Up-To-Date Nginx Container with Brotli

/ 7 min read

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. Even worse, the configuration file built into the container doesn’t direct 404 errors to a custom 404 page, using the default nginx page instead, which is totally unacceptable if you’ve gone through the trouble of building a custom 404 page.

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). Google provides a brotli module that can be loaded into nginx but which has to be built from source. I do have some experience compiling projects from source, but it’s usually enough trouble that I reach for prebuilt tools unless I encounter some glaring shortcoming. In this case, 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).

I did find this Dockerfile by KiweeEu which almost meets my requirements. It’s an ubuntu-based container, which is heavier and arguably less secure than alpine, but in and of itself that’s not a deal-breaker. Unusually, the author uses echo and > to modify the nginx configuration inside the Dockerfile instead of copying in a modified nginx.conf. Embedding the configuration changes inside the Dockerfile like this strikes me as appealingly self-contained, but for my purposes I would still have to either extend this Dockerfile via FROM or bind-mount a separate configuration via docker compose.

Since I want the flexibility to apply my own custom nginx configuration, using the same single-file structure as KiweeEu’s Dockerfile, I wanted to see whether I could use Claude1 to integrate KiweeEu’s Dockerfile into my own codebase and adapt it to my needs. Leveraging AI assistance, it was relatively painless to build my own self-updating nginx + brotli Dockerfile, while also improving upon the original in several key ways.

AI to the rescue

So, with KiweeEu’s Dockerfile as a starting point, here’s what Claude and I came up with:

Dockerfile
# Global ARG that can be used in all stages
ARG ALPINE_VERSION=3.20
# Base stage for building the static files
FROM node:lts AS base
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Version check stage
FROM alpine:${ALPINE_VERSION} AS version-check
RUN apk add --no-cache curl jq
# Only run the version check - this layer will be cached if the command output doesn't change
RUN curl -s https://api.github.com/repos/nginx/nginx/tags \
| jq -r '[.[].name | select(test("^release-"))] | .[0]' \
| sed 's/release-//' > /nginx-version
# Nginx builder stage
FROM alpine:${ALPINE_VERSION} AS nginx-builder
RUN apk add --no-cache \
build-base \
pcre-dev \
zlib-dev \
openssl-dev \
wget \
git \
brotli-dev
WORKDIR /app
# Copy the version from the previous stage
COPY --from=version-check /nginx-version ./nginx-version
# Use the version file as input to the RUN command, making it part of the cache key
RUN NGINX_VERSION=$(cat nginx-version) \
&& 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
FROM nginx:mainline-alpine AS runtime
COPY --from=version-check /nginx-version /tmp/nginx-version
RUN NGINX_VERSION=$(cat /tmp/nginx-version) \
&& if [ "$NGINX_VERSION" != "$(nginx -v 2>&1 | cut -d'/' -f2)" ]; then \
echo "Warning: Built modules version ($NGINX_VERSION) doesn't match nginx version $(nginx -v 2>&1 | cut -d'/' -f2)"; \
exit 1; \
fi
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
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
# Split the configuration into two files:
# 1. Brotli settings (and enable gzip static compression as well)
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
# 2. Server configuration with error pages
RUN printf 'server {\n\
listen 80;\n\
server_name _;\n\
root /usr/share/nginx/html;\n\
index index.html;\n\
\n\
error_page 404 /404.html;\n\
location = /404.html {\n\
internal;\n\
}\n\
}\n' > /etc/nginx/conf.d/default.conf
# Copy built files from base stage
COPY --from=base ./app/dist /usr/share/nginx/html
EXPOSE 80

my closing thoughts

Would I have gone through the trouble of writing this Dockerfile myself? Probably not. I would likely have just accepted the out-of-date dependencies or sub-optimal base image and bind-mounted my own configuration into conf.d. Having both a Dockerfile and an nginx.conf wouldn’t be the most elegant solution, but it would get the job done. But this new era of AI assistance means you don’t have to settle for hacky fixes if you know how to find a suitable foundation and articulate the results you want.

This Dockerfile still isn’t perfect. I should really be using a container registry to host the nginx + brotli container image, and then just copy the built static files from this blog into it alongside any additional configuration I want applied. But this approach ensures that I check for the latest nginx version and rebuild the brotli module every time I deploy a new version of this blog. If I want to use the same setup in multiple projects, I can just copy and paste the portions of the Dockerfile that are relevant for that project. Coding with AI has definitely made me more cavalier about code duplication.

Claude is an incredible force multiplier. I can’t say that I would have come up with the exact same solution on my own, but Claude’s solution is easy to follow and stands up to manual verification. Resolving the handful of bugs that arose in intermediate versions went much more quickly than it would have on my own. In the not-so-distant past building something like this would have taken hours of research and debugging, even with a good starting point. Now I can get it done and write about it between my daughter’s and my own bedtime.

Claude’s closing thoughts

Here’s what Claude had to say about the improvements we made from my own and KiweeEu’s initial Dockerfiles:

Here are the key improvements we made to the original Dockerfile:

  1. Automatic Version Detection: Added a dedicated version-check stage that queries GitHub’s API to always build against the latest nginx release.

  2. Efficient Multi-Stage Build:

    • Separated concerns into distinct build stages (static files, version check, nginx compilation, runtime)
    • Used the version check output as part of Docker’s cache key
    • Each stage is cached independently and only rebuilds when necessary
    • Nginx only recompiles when a new version is available
    • Static content can be updated without triggering nginx rebuild
  3. Alpine-Based Images:

    • Switched from Ubuntu to Alpine Linux for all stages
    • Reduced image size and attack surface
    • Updated package names and build dependencies for Alpine
  4. Improved Configuration Management:

    • Split nginx configuration into logical files
    • Separated brotli settings from server configuration
    • Added proper error page handling with custom 404 page support
  5. Version Verification:

    • Added runtime check to ensure the compiled modules match the nginx version
    • Fails fast if versions don’t match, preventing silent failures

The result is a maintainable, secure, and efficient container that automatically stays up to date with the latest nginx releases while keeping our custom configurations intact.

Thanks, Claude!

Footnotes

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