2026-08-03 22:33:46 -04:00
2026-08-03 16:38:34 -04:00
2026-08-03 22:33:46 -04:00
2026-08-03 16:37:20 -04:00
2026-08-03 22:33:46 -04:00
2026-08-03 16:37:20 -04:00
2026-08-03 22:33:46 -04:00

NginX Custom Binary → Nginx UI

Compile a custom nginx (or Angie) binary with third-party modules and inject it into an unmodified upstream Nginx UI deployment — no image fork, no rebuild of the Nginx UI application.

The build is completely standalone. It never starts, stops, or reads your stack — it just drops a binary into a folder your stack already mounts read-only.

   ./build.sh              (docker + git, that's all it needs)
        │
        ▼
   <stack>/custom/bin/{nginx,angie}      ← host folder
        │
        │  mounted :ro at /opt/custom/bin
        ▼
   NginxUI container
        NGINX_UI_NGINX_SBIN_PATH ──► which of the two it drives

Build

Run it straight off the repo on any Linux VM with docker and git:

curl -fsSL https://prod.git.gracesolution.info/Grace-Solutions/NginX-Custom-Binary/raw/branch/main/build.sh | bash

With options, note the -s --:

curl -fsSL .../build.sh | bash -s -- --flavor nginx

This compiles both nginx and Angie and drops them side by side in the stack's custom/bin folder, which the container sees at /opt/custom/bin. Switching between them later is an .env edit and a redeploy — no rebuild. --flavor nginx or --flavor angie builds just one.

From a checkout it uses the local builder/ directory; piped from a URL it clones the repo into a temp dir.

If a .env sits in the working directory it is picked up automatically for STACK_* and NGINXBUILD_* defaults. Precedence is CLI flag > container discovery > .env > shell env > built-in default.

Container discovery

The Nginx UI container is found automatically: running container names are matched case-insensitively against .*nginx.*ui.*, so NGINXUI-APP-001, nginx-ui and nginx_ui_1 all resolve without being told. Two things are then read straight off it:

  • The nginx version, via nginx -V. The custom binary must match, or it will not understand the bundled nginx.conf. If this differs from NGINXBUILD_VERSION in .env, the container wins and the script says so.
  • The bind mounts, via docker inspect. The export directory is whatever the container actually has mounted at NGINXUI_BINDIR — not a path reconstructed from STACK_* variables that may have drifted.

Check what it resolves without building anything:

./build.sh --discover
container   NGINXUI-APP-001 (running)
version     1.31.3

bind mounts
  /etc/nginx             rw  /custom/docker/stacks/stk-nginxui-001/nginx
  /etc/nginx-ui          rw  /custom/docker/stacks/stk-nginxui-001/nginx-ui
  /opt/custom/bin        ro  /custom/docker/stacks/stk-nginxui-001/custom/bin

export dir  /custom/docker/stacks/stk-nginxui-001/custom/bin
source      bind mount /opt/custom/bin on NGINXUI-APP-001

The export directory resolves in four tiers, first hit wins:

  1. --out DIR
  2. the container's bind mount for NGINXUI_BINDIR — authoritative, since that is where the binaries will actually be visible from inside the container
  3. the parent of its /etc/nginx mount + STACK_CUSTOMBINPATH, for when the bin mount has not been added to the stack yet (warns)
  4. the .env STACK_* derivation — /<STACK_BINDMOUNTROOT>/<STACK_NAME>/<STACK_CUSTOMBINPATH>

Named volumes are ignored; only bind mounts are considered. A stopped container still works for reading mounts — just not for probing the version.

Flag Effect
--match REGEX different pattern, e.g. --match nginxui-app-002
-c, --container NAME exact container, skip the search
--no-discover ignore containers entirely, use .env paths
--discover print findings and exit

If the pattern matches more than one container the script refuses and lists them rather than guessing.

Adding modules

Either edit builder/modules.txt (<git-url> [ref], one per line), or pass them at build time — repeatable, appended to the manifest:

./build.sh \
  -m https://github.com/vozlt/nginx-module-vts@v0.2.4 \
  -m https://github.com/nginx/njs

--modules-file PATH replaces the manifest outright; --no-modules builds clean. Omitting the @REF tracks the default branch, which is what the bundled manifest does; the script warns so the choice is visible. Add a tag or SHA to freeze one.

Most third-party modules lag upstream nginx by several releases. If one fails to compile, drop a patch in builder/patches/modules/<module-name>/*.patch rather than forking the module. Patches for the nginx/Angie source itself go in builder/patches/*.patch. Both apply with -p1.

Bundled modules

Three are enabled out of the box in builder/modules.txt. None are version-pinned — every module tracks its default branch, so each build picks up the latest upstream code. Add a tag or SHA as a second field to freeze one.

Module Gives you
nginx-ntlm-module ntlm; / ntlm_timeout in an upstream block — see the caveat below
headers-more more_set_headers / more_clear_headers — set, replace, or remove any response header, including upstream ones. add_header can only append.
substitutions-filter subs_filter — regex rewriting of response bodies, multiple patterns per location. Built-in sub_filter is fixed-string only.

All three are pure-nginx: no external -l flags, so nothing new is required of the runtime image.

The runtime library trap

This is the one failure mode that will take your stack down rather than degrade it, so it is worth understanding before adding modules.

The binary links dynamically against libraries provided by the Nginx UI container, not the builder. The loader resolves every dependency before main() runs — so a missing library is not a broken feature, it is a binary that cannot start. s6 then restarts it forever:

nginx: error while loading shared libraries: libmaxminddb.so.0:
       cannot open shared object file: No such file or directory

geoip2 is the known example, and it is disabled in the manifest for this reason: the stock image ships the legacy nginx-module-geoip (libgeoip), which is a different library from libmaxminddb. Enabling it means also getting libmaxminddb into the runtime image, which defeats the point of not touching that image.

build.sh now guards against this. After every build it runs ldd on each new binary inside the discovered container and refuses to give you a clean bill of health if anything is unresolved:

==> Checking shared libraries inside NGINXUI-APP-001
!!  [nginx] MISSING SHARED LIBRARIES in NGINXUI-APP-001:
           libmaxminddb.so.0 => not found

Done, but DO NOT DEPLOY YET.

If you hit this after deploying, set NGINXUI_SBINPATH=/usr/sbin/nginx and docker compose up -d NginxUI to fall back to the stock binary, then rebuild without the offending module.

NTLM caveat

  • Upstream's own README says it is not production-grade; NGINX Plus ships supported NTLM instead.
  • The repo has had no commits since June 2021, and open issues report it not working on recent nginx — #20 "1.30+ can't be effect" and #19 "Nginx1.28.1+ ExchangeSE only log: ntlm auth header found". The default target of 1.31.3 sits inside that range, so it may compile cleanly and still fail to hold connections at runtime.
  • Test against a real NTLM upstream before relying on it. If it needs source fixes, put them in builder/patches/modules/nginx-ntlm-module/*.patch.

Build without any of these via ./build.sh --no-modules, or comment lines out of builder/modules.txt.

Run ./build.sh --help for the full option list.

Swap the binary

One line in .env, then redeploy:

Target NGINXUI_SBINPATH build with
Stock bundled nginx (default) /usr/sbin/nginx
Custom nginx build /opt/custom/bin/nginx --flavor nginx
Angie /opt/custom/bin/angie --flavor angie
docker compose up -d NginxUI

NGINX_UI_NGINX_SBIN_PATH maps to Nginx UI's nginx.SbinPath setting (requires v2.1.10+). Rolling back is the same edit in reverse — the stock binary is never touched.

Why Angie is a drop-in

Angie is a fork of nginx by former nginx developers. It accepts the same directives, the same -t / -s reload CLI, and the same signal handling. The only thing that would normally break the illusion is its path layout — Angie defaults to /usr/local/angie and angie.conf.

builder/compile.sh compiles the angie flavor against the identical layout as the stock nginx (--prefix=/etc/nginx, --conf-path=/etc/nginx/nginx.conf, --pid-path=/run/nginx.pid, the same /var/cache/nginx/* temp paths, --user=nginx). So it reads the very same /etc/nginx tree Nginx UI edits, and Nginx UI needs no knowledge that it is not nginx — just SbinPath.

Use an Angie release from https://download.angie.software/files/, not an nginx version. The angie preset uses nginx-standard configure flags, which Angie's configure accepts by the same names; its module set is not identical though, so if a flag is rejected, replace the preset with --configure "...".

SbinPath vs. the running master process

SbinPath changes which binary Nginx UI invokes. It does not change what the container's entrypoint launched at boot — that is still /usr/sbin/nginx.

  • Config test (-t) uses the custom binary immediately. This is what makes the UI's editor accept new module syntax such as ntlm;.
  • Reload works immediately. -s reload signals the master via /run/nginx.pid; any nginx-family binary can send it.
  • The serving master stays stock until you restart nginx from the UI.

For the custom binary to serve from PID 1 onward, uncomment the overlay mount in docker-compose.yml:

- "/${STACK_BINDMOUNTROOT}/${STACK_NAME}/${STACK_CUSTOMBINPATH:-custom/bin}/nginx:/usr/sbin/nginx:ro"

The host file must already exist before up. Docker creates a directory at a missing bind-mount source, and the container will then fail to start.

ABI compatibility

The artifact links dynamically against libssl, libcrypto, libpcre2, libz and glibc — all provided by the Nginx UI container at runtime, not the builder. The builder base must therefore match.

--base debian:trixie (default) gives gcc 14.2 / glibc 2.41 / OpenSSL 3.5.x, matching the stock nginx -V output. For a guaranteed match, use --base uozi/nginx-ui:latest — same image, so same libraries by definition.

--with-http_v3_module needs OpenSSL 3.5+. On an older base, drop it via --configure.

Verification

# Modules present in the custom binary?
docker exec NGINXUI-APP-001 /opt/custom/bin/nginx -V

# Config parses under the custom binary?
docker exec NGINXUI-APP-001 /opt/custom/bin/nginx -t

# All shared libraries resolvable inside the RUNTIME container?
docker exec NGINXUI-APP-001 ldd /opt/custom/bin/nginx

An ldd line reading not found is the ABI mismatch above — fix --base, do not install libraries into the Nginx UI container.

Then, in the Nginx UI editor, add a block using a directive from your module (e.g. ntlm; inside an upstream) and save. It should validate rather than error.

The build also writes provenance next to the artifact:

File Contents
<name>.version.txt full -V output
<name>.ldd.txt resolved shared libraries at build time
<name>.sha256 checksum
<name>.modules.txt each module's git URL + exact commit SHA
<name>.build.env flavor, version, build timestamp, builder base
<name>.prev the binary this build replaced (NGINXBUILD_KEEPPREVIOUS=1)

Layout

build.sh                 standalone builder; curl-able, needs docker + git
docker-compose.yml       NginxUI only - consumes the binary, never builds it
.env                     stack vars (compose) + NGINXBUILD_* vars (build.sh)
builder/
  Dockerfile             stage 1 compile -> stage 2 export-only
  compile.sh             fetch, patch, configure, make (nginx | angie presets)
  install.sh             write-then-rename onto the output folder
  modules.txt            third-party module manifest
  patches/               optional *.patch, applied -p1
S
Description
Placeholder
Readme 108 KiB
Languages
Shell 90.2%
Dockerfile 9.8%