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 bundlednginx.conf. If this differs fromNGINXBUILD_VERSIONin.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 atNGINXUI_BINDIR— not a path reconstructed fromSTACK_*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:
--out DIR- the container's bind mount for
NGINXUI_BINDIR— authoritative, since that is where the binaries will actually be visible from inside the container - the parent of its
/etc/nginxmount +STACK_CUSTOMBINPATH, for when the bin mount has not been added to the stack yet (warns) - the
.envSTACK_*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 asntlm;. - Reload works immediately.
-s reloadsignals 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