From d5c7ec60a731bb8c02cd27a48fae09201c1d7629 Mon Sep 17 00:00:00 2001 From: hsxk Date: Tue, 11 Aug 2026 22:17:22 +0900 Subject: [PATCH 1/3] build on the official nginx image --- README.md | 76 ++++---- mainline/alpine/Dockerfile | 294 +++++++++---------------------- mainline/alpine/files/nginx.conf | 9 +- 3 files changed, 120 insertions(+), 259 deletions(-) diff --git a/README.md b/README.md index 6534f4a..8875c61 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,44 @@ # docker-nginx-quic -A small, hardened NGINX Docker image with HTTP/3 (QUIC), Brotli, headers-more, -and FastCGI cache-purge. HTTP/3 runs on **stock OpenSSL 3.5**, linked -dynamically against the distro package — no vendored TLS fork. Shipped as a -*toolbox*: the image gives you a sensible base config plus a library of -include-able snippets — you bring your own sites in `/etc/nginx/conf.d/` and -any http-level config in `/etc/nginx/http.d/`. +A small, hardened image built **on the official NGINX Alpine image**, with +HTTP/3 (QUIC), Brotli, headers-more, and FastCGI cache-purge. The official +nginx.org binary and its stock OpenSSL provide the server; this repository adds +only ABI-matched third-party dynamic modules, configuration, and entrypoint +behaviour. Shipped as a *toolbox*: bring sites in `/etc/nginx/conf.d/` and any +http-level config in `/etc/nginx/http.d/`. ## What's in the image | Component | Version (default) | Notes | |-------------------------|----------------------------------|------------------------------------| -| NGINX | `1.31.3` | tarball SHA256 verified | -| Alpine | `3.22` (pinned by digest) | oldest branch shipping OpenSSL 3.5 | -| OpenSSL | `3.5.x` (Alpine package) | for HTTP/3 — see below | +| Base image | `nginx:1.31.3-alpine3.24-slim` | official image, pinned by digest | +| NGINX | `1.31.3` | official nginx.org APK binary | +| Alpine | `3.24` | inherited from the official image | +| OpenSSL | `3.5.7` | inherited from the official image | | ngx_brotli | `google/ngx_brotli` @ pinned SHA | upstream, last update 2023-10 | | ngx_cache_purge | `nginx-modules/ngx_cache_purge` | active fork; nginx ≥1.25 compat | | headers-more-nginx | `0.40` | tarball SHA256 verified | -### Why the QUIC backend is plain OpenSSL now +### Why the base is the official image -Serving HTTP/3 used to require a patched TLS library, because upstream OpenSSL -had no server-side QUIC API. That is over: OpenSSL 3.5 ships one -(`SSL_set_quic_tls_cbs`), and nginx picks it automatically — the selection in -`src/event/quic/ngx_event_quic.h` is a plain `OPENSSL_VERSION_NUMBER >= 3.5.1` -check. +The pinned official image already supplies HTTP/3, HTTP/2, stream, threads, +AIO, and every standard HTTP module used by this repository. Its +nginx binary is installed from the signed nginx.org Alpine repository, so this +project no longer rebuilds or replaces `/usr/sbin/nginx`. -That matters for more than tidiness. A vendored TLS stack is **frozen at the -version you pinned and invisible to everything that looks for CVEs**: it is not -an `apk` package, so it does not appear in the image SBOM and scanners cannot -see it. quictls' last QUIC release is based on OpenSSL 3.3.0 (April 2024) and -the project wound down once OpenSSL 3.5 LTS landed, so that pin was accumulating -unpatched OpenSSL advisories that nothing would report. Linking the distro -package means a base-image rebuild picks up Alpine's security updates. +Only third-party modules absent from upstream are compiled. They use +`--add-dynamic-module` against the exact source version and configure feature +set of the pinned official binary, then load from `/usr/lib/nginx/modules`. +Changing `NGINX_FROM_IMAGE` therefore requires changing `NGINX_VERSION` and its +source checksum together; the build verifies the versions match before doing +any compilation. -The build fails loudly rather than degrading: if the OpenSSL headers are older -than 3.5.1, nginx would silently fall back to its `NGX_QUIC_OPENSSL_COMPAT` -shim, so the Dockerfile compiles a version assertion before `./configure`. +Every external module source is pinned to either an immutable git commit SHA or +a release tag whose tarball is SHA256-verified. -Every external source is pinned to either an immutable git commit SHA or a -release tag whose tarball is SHA256-verified at build time — flip a version -and the matching `*_SHA256` ARG together when bumping. +### What this adds to official `nginx:alpine` -### How this compares to the official `nginx:alpine` - -Worth being precise, because the gap is narrower than it used to be. As of -1.31.3 the official image is *also* built with `--with-http_v3_module` against -OpenSSL 3.5.7 — HTTP/3 is no longer a reason to leave it. What this image adds -is the third-party module set, which official does not ship in any form: +The runtime is the official image. This repository layers the following on it: | | official `nginx:alpine` | this image | |---|---|---| @@ -57,14 +48,9 @@ is the third-party module set, which official does not ship in any form: | `headers-more` | — | yes | | cache purge | — | `ngx_cache_purge` | | zstd / njs / GeoIP2 / VTS | — | opt-in build args | -| mail proxy | yes | **no** (see below) | +| mail proxy | yes | yes, inherited but not configured | | config + snippet toolbox | bare default vhost | the point of this image | -`--with-mail` / `--with-mail_ssl_module` are the one thing official has that this -does not, deliberately: an SMTP/IMAP/POP3 proxy is a different daemon role, -nothing in this image's config or snippets addresses it, and leaving it out -keeps a protocol parser off the attack surface. Open an issue if you need it. - Modules removed vs. the previous image: * `--with-http_image_filter_module` — pulled in `gd-dev` + `libpng-dev`, almost never used. * `--with-http_xslt_module` — never used here. @@ -414,9 +400,8 @@ the QUIC listener actually bound, OpenSSL new enough for the native QUIC API). ## Verifying HTTP/3 -The image is built with `--with-http_v3_module` against OpenSSL 3.5 — HTTP/3 -support is compiled in, and the build refuses to proceed on an OpenSSL too old -to serve it. To verify it works **end-to-end**: +The pinned official image is built with `--with-http_v3_module` against OpenSSL +3.5.7. To verify it works **end-to-end**: ### 1. NGINX side @@ -515,7 +500,6 @@ The base config opts into every cheap-to-enable win nginx ships: | Tuning | Where | Why | |----------------------------------------|--------------------------------|---------------------------------------------| -| `pcre_jit on` | main scope | JIT-compile every regex (locations, server_name). Built with `--with-pcre-jit`. | | `aio threads=default` + `aio_write on` | http {} | Offload blocking disk I/O to a 32-thread pool. Built with `--with-threads --with-file-aio`. | | `thread_pool default threads=32 max_queue=65536` | main scope | Backs the `aio` directive. | | `sendfile_max_chunk 2m` | http {} | One slow client can't starve a worker. | @@ -558,8 +542,8 @@ zstd -k -19 dist/**/*.{html,css,js,svg} `.github/workflows/docker-build.yml` runs in two stages: -* **matrix-build** — on every PR + push, builds 6 image flavors - (`base`, `zstd`, `njs`, `geoip2`, `vts`, `all`) on a single arch, runs +* **matrix-build** — on every PR + push, builds 7 image flavors + (`base`, `zstd`, `njs`, `njs-xml`, `geoip2`, `vts`, `all`) on a single arch, runs `nginx -V` and `nginx -t` inside each image. No registry push. Uses GitHub Actions cache per-flavor so re-runs are fast. * **release** — on tag pushes only, builds the multi-arch (`linux/amd64`, diff --git a/mainline/alpine/Dockerfile b/mainline/alpine/Dockerfile index 9580286..81ef32e 100644 --- a/mainline/alpine/Dockerfile +++ b/mainline/alpine/Dockerfile @@ -1,64 +1,32 @@ # syntax=docker/dockerfile:1.7 # -# Hardened NGINX image with HTTP/3 (QUIC), Brotli, headers-more, cache-purge. +# NGINX toolbox based on the official nginx.org Alpine image. # -# QUIC backend: the DISTRO's OpenSSL 3.5.x, linked dynamically. -# nginx >= 1.29 speaks OpenSSL's native QUIC server API (SSL_set_quic_tls_cbs), -# which landed in OpenSSL 3.5 — so the old dance of statically linking a -# BoringSSL/quictls fork is no longer necessary. See the QUIC note below. -# -# Optional modules (off by default — flip the ARG to "1"): -# ENABLE_ZSTD zstd compression (tokers/zstd-nginx-module) -# Chrome 123+ / Firefox 126+ negotiate `Accept-Encoding: zstd` -# and zstd is faster than brotli at equivalent ratios. -# ENABLE_NJS njs — nginx-flavored JS for replacing complex `if` chains -# (https://nginx.org/en/docs/njs/) -# ENABLE_GEOIP2 leev/ngx_http_geoip2_module (replaces obsolete GeoIP1) -# ENABLE_VTS vozlt/nginx-module-vts — Prometheus-friendly `/status` -# -# Build (with everything optional turned on): -# docker build -t my-nginx \ -# --build-arg ENABLE_ZSTD=1 \ -# --build-arg ENABLE_NJS=1 \ -# --build-arg ENABLE_GEOIP2=1 \ -# --build-arg ENABLE_VTS=1 \ -# -f mainline/alpine/Dockerfile . +# The official binary supplies HTTP/2, HTTP/3 (QUIC), stream, threads, AIO, +# and the standard HTTP modules used by this repository. We only +# build the third-party functionality missing upstream, as ABI-matched dynamic +# modules. Keeping the nginx binary owned by the official image means nginx, +# OpenSSL, and Alpine security updates no longer depend on our compiler recipe. -# Alpine 3.22 is the oldest branch shipping OpenSSL 3.5 (the QUIC-capable one) -# and is supported until 2027-05. Do not drop below this: 3.21 and older ship -# OpenSSL 3.3, whose QUIC API is client-only and cannot serve HTTP/3. -# -# Pinned by digest, not just by tag: `alpine:3.22` is a moving target, so two -# builds of the same commit could otherwise produce different images — which -# would make the "byte-reproducible" claim below false. The tradeoff is that -# base security updates now need a deliberate bump; the Trivy job in CI is what -# tells you when this pin has gone stale. -# -# To bump: docker buildx imagetools inspect alpine:3.22 --format '{{.Manifest.Digest}}' -ARG ALPINE_VERSION=3.22 -ARG ALPINE_DIGEST=sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce -FROM alpine:${ALPINE_VERSION}@${ALPINE_DIGEST} AS builder +ARG NGINX_FROM_IMAGE=nginx:1.31.3-alpine3.24-slim@sha256:45b82ed5f285b90d63df07ba70430fdd8f25624b416617d9e6dc93412b2006dc + +FROM ${NGINX_FROM_IMAGE} AS module-builder -# ─── Pinned source versions (bump deliberately) ───────────────────────────── -# All refs are pinned to immutable commit SHAs / signed release tags so the -# build is byte-reproducible. To bump: update both REF and the matching SHA256. ARG NGINX_VERSION=1.31.3 ARG NGINX_SHA256=a7657c50811c2d92d9895395e8b873ef60398142c4db21eb647811c38f6dd525 +# Optional build-only mirror override for environments where Alpine's CDN is +# slow. It does not change the repositories baked into the runtime image. +ARG ALPINE_MIRROR= ARG HEADERS_MORE_VERSION=0.40 ARG HEADERS_MORE_SHA256=c14cb5e6c998590c209efbf77bd7637ce2cab02332e4192756a8af5b26ba4284 -# google/ngx_brotli is the upstream — last touched 2023-10-09, NOT archived. ARG NGX_BROTLI_REPO=https://github.com/google/ngx_brotli ARG NGX_BROTLI_REF=a71f9312c2deb28875acc7bacfdd5695a111aa53 -# Original FRiCKLE/ngx_cache_purge stopped at 2014. nginx-modules/ngx_cache_purge -# is the actively maintained fork (nginx ≥1.25 compatible, fix for the -# proxy_cache_purge ctx leak landed 2025). ARG NGX_CACHE_PURGE_REPO=https://github.com/nginx-modules/ngx_cache_purge ARG NGX_CACHE_PURGE_REF=285354eddd5675c765ba2b79dac09f5d3065b22f -# Optional modules — versions used only when their ENABLE_* arg is "1". ARG ZSTD_NGINX_REPO=https://github.com/tokers/zstd-nginx-module ARG ZSTD_NGINX_REF=057a7d339af1111d04b5a9ac5ae9b0250d17cd94 @@ -73,52 +41,25 @@ ARG VTS_SHA256=0003bfa57da2106367fdc3f87760910c046e7df5545db73c880fc2b80350b14c ARG ENABLE_ZSTD=0 ARG ENABLE_NJS=0 +ARG ENABLE_NJS_XML=0 ARG ENABLE_GEOIP2=0 ARG ENABLE_VTS=0 -# njs sub-feature. njs/nginx/config defaults NJS_LIBXSLT to YES, which enables -# njs's xml module and makes nginx's configure demand libxml2 + libxslt — so -# ENABLE_NJS=1 alone fails the build with "the HTTP XSLT module requires the -# libxml2/libxslt libraries" unless those dev packages are present. We default -# it OFF instead of adding the libraries: njs here is for routing and header -# rewrites, and libxml2 is a large, steady source of CVEs to carry in the -# runtime for a feature almost nobody enabling njs is after. -# Set to 1 if you actually parse XML in njs. -ARG ENABLE_NJS_XML=0 - -# Build-only deps. Note: no gd/libpng/freetype/xpm/tiff/perl/xslt — those -# would only be needed for image_filter / perl / xslt modules we don't build. -# -# ─── Why openssl-dev and not a vendored quictls/BoringSSL ─────────────────── -# Building a TLS stack from a pinned tarball freezes it: quictls' last QUIC -# release is based on OpenSSL 3.3.0 (April 2024) and the project wound down -# once OpenSSL 3.5 LTS shipped the server-side QUIC API. Statically linking it -# means (a) every OpenSSL CVE needs a source bump + full rebuild, and (b) the -# vulnerable version is invisible to `apk`, to SBOM generators and to image -# scanners, because there is no package to report. -# -# Linking against the distro's OpenSSL 3.5.x inverts all of that: a base-image -# rebuild picks up Alpine's security updates, the version is in the SBOM, and -# scanners can actually see it. nginx detects the native API automatically — -# `./configure` probes for SSL_set_quic_tls_cbs() and enables HTTP/3 with it. -RUN apk add --no-cache --virtual .build \ - build-base git \ - ca-certificates curl \ - linux-headers \ - pcre2-dev zlib-dev brotli-dev openssl-dev \ - libmaxminddb-dev +RUN set -eux; \ + actual="$(nginx -v 2>&1 | sed 's|.*/||')"; \ + [ "$actual" = "${NGINX_VERSION}" ]; \ + if [ -n "${ALPINE_MIRROR}" ]; then \ + sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories; \ + fi; \ + apk add --no-cache --virtual .module-build \ + build-base git curl ca-certificates linux-headers \ + pcre2-dev zlib-dev brotli-dev openssl-dev libmaxminddb-dev; \ + if [ "${ENABLE_ZSTD}" = "1" ]; then apk add --no-cache zstd-dev; fi; \ + if [ "${ENABLE_NJS_XML}" = "1" ]; then apk add --no-cache libxml2-dev libxslt-dev; fi WORKDIR /tmp/build -# ─── nginx + core modules ─────────────────────────────────────────────────── -# Every curl download is SHA256-verified; git fetches pin an immutable commit -# (git is content-addressable, so the SHA *is* the checksum). We fetch that one -# commit at depth 1 rather than cloning full history. -# -# ngx_brotli's submodule update stays a full clone on purpose: ngx_brotli pins -# deps/brotli to a commit that is not the tip of any branch, and a shallow -# fetch of an unadvertised object is exactly what git servers refuse. One small -# submodule cloned in full is cheap; an intermittently failing build is not. +# Fetch nginx and every enabled module from an immutable, verified source. RUN set -eux; \ curl -fSLo nginx.tar.gz "https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz"; \ echo "${NGINX_SHA256} nginx.tar.gz" | sha256sum -c -; \ @@ -126,205 +67,142 @@ RUN set -eux; \ rm -f nginx.tar.gz; \ mkdir -p "nginx-${NGINX_VERSION}/extensions"; \ cd "nginx-${NGINX_VERSION}/extensions"; \ - \ shallow_checkout() { \ - mkdir -p "$1" && cd "$1"; \ + mkdir -p "$1"; \ + cd "$1"; \ git init -q .; \ git remote add origin "$2"; \ git fetch -q --depth 1 origin "$3"; \ git checkout -q FETCH_HEAD; \ cd ..; \ }; \ - \ shallow_checkout ngx_brotli "${NGX_BROTLI_REPO}" "${NGX_BROTLI_REF}"; \ (cd ngx_brotli && git submodule update --init --recursive); \ - \ shallow_checkout ngx_cache_purge "${NGX_CACHE_PURGE_REPO}" "${NGX_CACHE_PURGE_REF}"; \ - \ curl -fSLo headers-more.tgz \ "https://github.com/openresty/headers-more-nginx-module/archive/v${HEADERS_MORE_VERSION}.tar.gz"; \ echo "${HEADERS_MORE_SHA256} headers-more.tgz" | sha256sum -c -; \ - tar -xzf headers-more.tgz && mv "headers-more-nginx-module-${HEADERS_MORE_VERSION}" headers-more; \ - rm -f headers-more.tgz - -# ─── Optional modules (all sources checksum-verified) ─────────────────────── -RUN set -eux; cd "/tmp/build/nginx-${NGINX_VERSION}/extensions"; \ + tar -xzf headers-more.tgz; \ + mv "headers-more-nginx-module-${HEADERS_MORE_VERSION}" headers-more; \ + rm -f headers-more.tgz; \ if [ "${ENABLE_ZSTD}" = "1" ]; then \ - mkdir -p zstd-nginx-module && cd zstd-nginx-module; \ - git init -q .; \ - git remote add origin "${ZSTD_NGINX_REPO}"; \ - git fetch -q --depth 1 origin "${ZSTD_NGINX_REF}"; \ - git checkout -q FETCH_HEAD; \ - cd ..; \ - apk add --no-cache --virtual .zstd-build zstd-dev; \ + shallow_checkout zstd-nginx-module "${ZSTD_NGINX_REPO}" "${ZSTD_NGINX_REF}"; \ fi; \ if [ "${ENABLE_NJS}" = "1" ]; then \ curl -fSLo njs.tgz "https://github.com/nginx/njs/archive/refs/tags/${NJS_VERSION}.tar.gz"; \ echo "${NJS_SHA256} njs.tgz" | sha256sum -c -; \ - tar -xzf njs.tgz && mv "njs-${NJS_VERSION}" njs && rm njs.tgz; \ - if [ "${ENABLE_NJS_XML}" = "1" ]; then \ - apk add --no-cache --virtual .njs-xml-build libxml2-dev libxslt-dev; \ - fi; \ + tar -xzf njs.tgz; \ + mv "njs-${NJS_VERSION}" njs; \ + rm -f njs.tgz; \ fi; \ if [ "${ENABLE_GEOIP2}" = "1" ]; then \ curl -fSLo geoip2.tgz "https://github.com/leev/ngx_http_geoip2_module/archive/refs/tags/${GEOIP2_VERSION}.tar.gz"; \ echo "${GEOIP2_SHA256} geoip2.tgz" | sha256sum -c -; \ - tar -xzf geoip2.tgz && mv "ngx_http_geoip2_module-${GEOIP2_VERSION}" geoip2 && rm geoip2.tgz; \ + tar -xzf geoip2.tgz; \ + mv "ngx_http_geoip2_module-${GEOIP2_VERSION}" geoip2; \ + rm -f geoip2.tgz; \ fi; \ if [ "${ENABLE_VTS}" = "1" ]; then \ curl -fSLo vts.tgz "https://github.com/vozlt/nginx-module-vts/archive/refs/tags/v${VTS_VERSION}.tar.gz"; \ echo "${VTS_SHA256} vts.tgz" | sha256sum -c -; \ - tar -xzf vts.tgz && mv "nginx-module-vts-${VTS_VERSION}" vts && rm vts.tgz; \ + tar -xzf vts.tgz; \ + mv "nginx-module-vts-${VTS_VERSION}" vts; \ + rm -f vts.tgz; \ fi -# ─── Guard: the QUIC API nginx picks is decided purely by the OpenSSL headers. -# src/event/quic/ngx_event_quic.h selects NGX_QUIC_OPENSSL_API (the real, -# server-capable API) only when OPENSSL_VERSION_NUMBER >= 3.5.1; anything older -# silently falls back to NGX_QUIC_OPENSSL_COMPAT, a TLS-callback shim that -# nginx ships for completeness but which is materially slower and misses 0-RTT. -# Fail the build here rather than shipping an image that advertises h3 and -# under-delivers. -RUN set -eux; \ - printf '%s\n' \ - '#include ' \ - '#if OPENSSL_VERSION_NUMBER < 0x30500010L' \ - '#error "need OpenSSL >= 3.5.1 for the native QUIC server API"' \ - '#endif' > /tmp/ossl-quic-check.c; \ - cc -fsyntax-only /tmp/ossl-quic-check.c; \ - rm -f /tmp/ossl-quic-check.c - -# ─── Configure & build nginx ──────────────────────────────────────────────── +# Match the configure feature set of the pinned official nginx package. The +# official binary is built with --with-compat, so these .so files can be loaded +# without replacing /usr/sbin/nginx. RUN set -eux; \ cd "/tmp/build/nginx-${NGINX_VERSION}"; \ - \ if [ "${ENABLE_NJS_XML}" = "1" ]; then NJS_LIBXSLT=YES; else NJS_LIBXSLT=NO; fi; \ export NJS_LIBXSLT; \ - \ - EXTRA_MODULES=""; \ - [ "${ENABLE_ZSTD}" = "1" ] && EXTRA_MODULES="$EXTRA_MODULES --add-module=./extensions/zstd-nginx-module"; \ - [ "${ENABLE_NJS}" = "1" ] && EXTRA_MODULES="$EXTRA_MODULES --add-module=./extensions/njs/nginx"; \ - [ "${ENABLE_GEOIP2}" = "1" ] && EXTRA_MODULES="$EXTRA_MODULES --add-module=./extensions/geoip2"; \ - [ "${ENABLE_VTS}" = "1" ] && EXTRA_MODULES="$EXTRA_MODULES --add-module=./extensions/vts"; \ - \ + modules="--add-dynamic-module=./extensions/ngx_cache_purge \ + --add-dynamic-module=./extensions/ngx_brotli \ + --add-dynamic-module=./extensions/headers-more"; \ + [ "${ENABLE_ZSTD}" = "1" ] && modules="$modules --add-dynamic-module=./extensions/zstd-nginx-module"; \ + [ "${ENABLE_NJS}" = "1" ] && modules="$modules --add-dynamic-module=./extensions/njs/nginx"; \ + [ "${ENABLE_GEOIP2}" = "1" ] && modules="$modules --add-dynamic-module=./extensions/geoip2"; \ + [ "${ENABLE_VTS}" = "1" ] && modules="$modules --add-dynamic-module=./extensions/vts"; \ ./configure \ --prefix=/etc/nginx \ --sbin-path=/usr/sbin/nginx \ --modules-path=/usr/lib/nginx/modules \ --conf-path=/etc/nginx/nginx.conf \ - --http-log-path=/var/log/nginx/access.log \ --error-log-path=/var/log/nginx/error.log \ - --pid-path=/var/run/nginx.pid \ - --lock-path=/var/run/nginx.lock \ + --http-log-path=/var/log/nginx/access.log \ + --pid-path=/run/nginx.pid \ + --lock-path=/run/nginx.lock \ --http-client-body-temp-path=/var/cache/nginx/client_temp \ --http-proxy-temp-path=/var/cache/nginx/proxy_temp \ --http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp \ --http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp \ --http-scgi-temp-path=/var/cache/nginx/scgi_temp \ + --with-perl_modules_path=/usr/lib/perl5/vendor_perl \ --user=nginx --group=nginx \ - --with-threads --with-file-aio --with-compat --with-pcre-jit \ - --with-http_ssl_module \ - --with-http_v2_module \ - --with-http_v3_module \ - --with-http_realip_module \ - --with-http_addition_module \ - --with-http_sub_module \ - --with-http_gunzip_module \ - --with-http_gzip_static_module \ - --with-http_auth_request_module \ - --with-http_random_index_module \ - --with-http_secure_link_module \ - --with-http_slice_module \ - --with-http_stub_status_module \ - # Present in the official nginx image, so their absence here would be a - # silent regression for anyone migrating. All three are core modules with - # no external dependencies — they cost bytes, not attack surface. - # dav WebDAV PUT/DELETE/MKCOL/COPY/MOVE - # mp4 ?start= pseudo-streaming (plain Range seeking works without it) - # flv ?start= for the legacy FLV container - --with-http_dav_module \ - --with-http_mp4_module \ - --with-http_flv_module \ - --with-stream --with-stream_ssl_module \ - --with-stream_ssl_preread_module --with-stream_realip_module \ - --add-module=./extensions/ngx_cache_purge \ - --add-module=./extensions/ngx_brotli \ - --add-module=./extensions/headers-more \ - $EXTRA_MODULES \ - --with-cc-opt="-O2 -fstack-protector-strong -Wformat -Werror=format-security \ - -Wno-deprecated-declarations -Wno-cast-function-type \ - -Wno-unused-parameter -Wno-stringop-truncation -Wno-stringop-overflow" \ - --with-ld-opt="-Wl,--as-needed -Wl,-z,relro -Wl,-z,now"; \ - make -j"$(nproc)"; \ - make install; \ - strip /usr/sbin/nginx - -# ───────────────────────────────────────────────────────────────────────────── -# Runtime image -# ───────────────────────────────────────────────────────────────────────────── -FROM alpine:${ALPINE_VERSION}@${ALPINE_DIGEST} + --with-compat --with-file-aio --with-threads \ + --with-http_addition_module --with-http_auth_request_module \ + --with-http_dav_module --with-http_flv_module \ + --with-http_gunzip_module --with-http_gzip_static_module \ + --with-http_mp4_module --with-http_random_index_module \ + --with-http_realip_module --with-http_secure_link_module \ + --with-http_slice_module --with-http_ssl_module \ + --with-http_stub_status_module --with-http_sub_module \ + --with-http_v2_module --with-http_v3_module \ + --with-mail --with-mail_ssl_module \ + --with-stream --with-stream_realip_module \ + --with-stream_ssl_module --with-stream_ssl_preread_module \ + $modules; \ + make -j"$(nproc)" modules; \ + mkdir -p /tmp/modules; \ + cp objs/*.so /tmp/modules/; \ + strip /tmp/modules/*.so; \ + : > /tmp/modules/00-third-party.conf; \ + for module in /tmp/modules/*.so; do \ + printf 'load_module modules/%s;\n' "$(basename "$module")" >> /tmp/modules/00-third-party.conf; \ + done + +FROM ${NGINX_FROM_IMAGE} LABEL org.opencontainers.image.title="docker-nginx-quic" \ - org.opencontainers.image.description="NGINX with HTTP/3 (OpenSSL 3.5), Brotli, headers-more, cache-purge" \ + org.opencontainers.image.description="Official NGINX with HTTP/3, Brotli, headers-more, and cache-purge" \ org.opencontainers.image.source="https://github.com/hsxk/docker-nginx" \ org.opencontainers.image.licenses="GPL-3.0-or-later" -# Optional-module runtime deps need re-declaring here (ARGs reset across stages). ARG ENABLE_ZSTD=0 ARG ENABLE_GEOIP2=0 ARG ENABLE_NJS_XML=0 RUN set -eux; \ - addgroup -g 101 -S nginx; \ - adduser -S -D -H -u 101 -h /var/cache/nginx -s /sbin/nologin -G nginx -g nginx nginx; \ - # Runtime libs only. No libxslt (we don't build xslt module). - # `openssl` brings in libssl3/libcrypto3 (which the nginx binary links - # against) and gives the entrypoint the CLI it needs to mint the fallback - # self-signed cert. No curl: busybox wget covers the HEALTHCHECK. - apk add --no-cache \ - ca-certificates tzdata pcre2 zlib brotli-libs openssl; \ - if [ "${ENABLE_ZSTD}" = "1" ]; then apk add --no-cache zstd-libs; fi; \ - if [ "${ENABLE_GEOIP2}" = "1" ]; then apk add --no-cache libmaxminddb; fi; \ + apk add --no-cache openssl brotli-libs; \ + if [ "${ENABLE_ZSTD}" = "1" ]; then apk add --no-cache zstd-libs; fi; \ + if [ "${ENABLE_GEOIP2}" = "1" ]; then apk add --no-cache libmaxminddb; fi; \ if [ "${ENABLE_NJS_XML}" = "1" ]; then apk add --no-cache libxml2 libxslt; fi; \ - mkdir -p /etc/nginx/snippets /etc/nginx/conf.d /etc/nginx/http.d \ - /usr/share/nginx-defaults \ + rm -f /etc/nginx/conf.d/default.conf; \ + mkdir -p /etc/nginx/modules-enabled /etc/nginx/snippets \ + /etc/nginx/conf.d /etc/nginx/http.d /usr/share/nginx-defaults \ /var/cache/nginx /var/log/nginx /var/www/html /etc/letsencrypt; \ chown -R nginx:nginx /var/cache/nginx /var/log/nginx /var/www/html; \ - # Container best-practice: stream nginx logs to docker's stdout/stderr. - # NOTE: deliberately no `VOLUME /var/log/nginx` — declaring one would spawn - # an anonymous volume on every `docker run` that shadows these symlinks and - # silently diverts logs to disk instead of `docker logs`. ln -sf /dev/stdout /var/log/nginx/access.log; \ ln -sf /dev/stderr /var/log/nginx/error.log -# nginx binary + bundled defaults -COPY --from=builder /usr/sbin/nginx /usr/sbin/nginx -COPY --from=builder /etc/nginx/mime.types /etc/nginx/mime.types -COPY --from=builder /etc/nginx/fastcgi.conf /etc/nginx/fastcgi.conf -COPY --from=builder /etc/nginx/uwsgi_params /etc/nginx/uwsgi_params -COPY --from=builder /etc/nginx/scgi_params /etc/nginx/scgi_params +COPY --from=module-builder /tmp/modules/*.so /usr/lib/nginx/modules/ +COPY --from=module-builder /tmp/modules/00-third-party.conf /etc/nginx/modules-enabled/00-third-party.conf -# Our configuration COPY mainline/alpine/files/nginx.conf /etc/nginx/nginx.conf COPY mainline/alpine/files/fastcgi_params /etc/nginx/fastcgi_params COPY mainline/alpine/files/resolver.conf /etc/nginx/resolver.conf COPY mainline/alpine/files/quic-bpf.conf /etc/nginx/quic-bpf.conf COPY mainline/alpine/files/snippets/ /etc/nginx/snippets/ -# Built-in fallback vhost — entrypoint installs into conf.d/ when empty. COPY mainline/alpine/files/default-conf.d/ /usr/share/nginx-defaults/ COPY mainline/alpine/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh -# 80 = HTTP (redirect + ACME) -# 443/tcp = HTTPS (TLS 1.2/1.3 over TCP, h2 + h1.1) -# 443/udp = HTTP/3 (QUIC) -# (8081 is the health listener — bound to 127.0.0.1 only, never published.) EXPOSE 80/tcp 443/tcp 443/udp STOPSIGNAL SIGQUIT -# Hits the loopback-only health server defined in nginx.conf, NOT a vhost in -# conf.d/ — so mounting your own conf.d can never make the container unhealthy. HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ CMD wget -q -O /dev/null http://127.0.0.1:8081/healthz || exit 1 diff --git a/mainline/alpine/files/nginx.conf b/mainline/alpine/files/nginx.conf index 2a7f17f..92d5471 100644 --- a/mainline/alpine/files/nginx.conf +++ b/mainline/alpine/files/nginx.conf @@ -4,15 +4,14 @@ ## Reusable building blocks live in /etc/nginx/snippets/ — see the examples/ ## directory in this repository. +# Third-party modules are ABI-matched dynamic modules built against the pinned +# official nginx image. Optional builds simply add more files to this glob. +include /etc/nginx/modules-enabled/*.conf; + user nginx; worker_processes auto; worker_rlimit_nofile 65535; -# Enable PCRE JIT — we compiled with --with-pcre-jit, but the runtime -# directive must be set explicitly to actually JIT-compile regex location -# blocks, server_name patterns, etc. Cheap, big win on regex-heavy configs. -pcre_jit on; - # A thread pool for offloading blocking I/O (sendfile of cold pages, etc.). # Used by `aio threads;` below. thread_pool default threads=32 max_queue=65536; From 37c3e01b665d5c969441db0d0922520ac6102a15 Mon Sep 17 00:00:00 2001 From: hsxk Date: Tue, 11 Aug 2026 22:23:30 +0900 Subject: [PATCH 2/3] upload the tested CI image --- .github/workflows/docker-build.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 5ebca0b..0805abe 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -85,6 +85,22 @@ jobs: ignore-unfixed: true exit-code: '0' + # Keep the already-tested base image available after the ephemeral + # runner exits. This lets maintainers download and run the exact CI + # output without rebuilding it on a development machine. + - name: Export tested base image + if: matrix.flavor.name == 'base' + run: docker save "${IMAGE}:ci-base" | gzip -1 > "${RUNNER_TEMP}/nginx-base.tar.gz" + + - name: Upload tested base image + if: matrix.flavor.name == 'base' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: nginx-base-${{ github.sha }} + path: ${{ runner.temp }}/nginx-base.tar.gz + compression-level: 0 + retention-days: 3 + # ───────────────────────────────────────────────────────────────────────── # Release build: multi-arch, signed provenance + SBOM, pushed to Docker Hub. # Runs only on tag pushes; gated behind a successful matrix-build. From 9187bba878c7d084b021a9b2917a971ecde8f6a7 Mon Sep 17 00:00:00 2001 From: hsxk Date: Tue, 11 Aug 2026 22:32:35 +0900 Subject: [PATCH 3/3] fix the UDP listener probe --- tests/smoke.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/smoke.sh b/tests/smoke.sh index a834891..9a3a8d0 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -69,7 +69,8 @@ case "$nginx_v" in esac echo "==> listeners" -if docker exec "$NAME" netstat -lun 2>/dev/null | grep -q ':443'; then +udp_listeners=$(docker exec "$NAME" netstat -lun 2>/dev/null || true) +if grep -q ':443' <<<"$udp_listeners"; then pass "QUIC/UDP :443 bound" else bad "no UDP listener on :443 — HTTP/3 would be advertised but dead"