15.3 Dynamic Modules & Binary Compatibility
One directive loads new code into a running architecture; one signature rule decides whether it loads at all. The exact error a mismatch produces — and the version-pinning that prevents it.
Advanced
What you will learn
load_module: placement, semantics, and what loading actually does.The binary-compatibility rule — and the real receipt of violating it.
--with-compat: what it relaxes, what it doesn't.Module hygiene: versioning, upgrades, and supply chain.
1.
load_module
# main context — FIRST thing in the file, before events{} and http{}
load_module modules/ngx_http_js_module.so;
load_module modules/ngx_http_brotli_filter_module.so;
load_module modules/ngx_http_brotli_static_module.so;Mechanics worth knowing precisely:
Path is prefix-relative (like everything else in this series — build once into a shared
~/nginx/modules/, then symlink it into each prefix asmodules/).Loading happens before config parsing of http{} — the module's directives (
js_set,brotli on) must exist by the time the parser meets them. That's why the lines live at the top, and why a missing module is a[emerg]at boot, not a runtime limp.One .so = one module — ngx_brotli ships two (a filter for dynamic compression, a static module for pre-compressed
.brfiles); both load, both matter.The module brings its own directives — after loading,
nginx -tvalidates them like natives. There is no "half-loaded" state.
2. The rule, and our receipt of breaking it
A dynamic module embeds a
signature — a fingerprint of the nginx build it was compiled against: version, feature flags that change structure layouts (
--with-threads,
--with-file-aio, TLS/pcre choices...). At
load_module time, nginx compares signatures. Mismatch = refusal.
Our first brotli build refused, with this exact receipt:
[emerg] module ".../ngx_http_brotli_filter_module.so" is not binary compatibleWhy it happened (the honest postmortem): we configured the module build with a
reasonable minimal flag set —
--with-compat plus the module. But the packaged binary was built with
--with-threads --with-file-aio --with-http_ssl_module --with-http_v2_module ... — a twenty-five-flag reality. Threads and AIO change core structure layouts; "reasonable minimal" wasn't compatible, just minimal.
The fix — the bulletproof recipe:
Configure the module with the EXACT arguments the target binary reports via
nginx -V, plus your--add-dynamic-module.
nginx -V 2>&1 | tr ' ' '\n' | grep '^--' # the target's truth
# ...then mirror them all in ./configure for the module buildThat recipe made the same source, same module, same binary suddenly compatible. It also explains the distro practice you've seen in the wild: module packages (
nginx-module-njs,
nginx-module-geoip) are version-locked to their nginx package —
1.30.5+1.0.1-1~trixie, module build and binary build in one versioned pair.
3.
--with-compat — what it does and doesn't
--with-compat (on BOTH sides — binary and module) relaxes the signature check for flags that
don't affect structure layout: it lets a module built without
--with-http_v3_module load into a binary built with it (and vice versa), because that flag only gates module inclusion, not core structs.
What it does NOT do:
excuse a version mismatch (1.30.5 module ≠ 1.31 binary),
excuse layout-affecting differences (
--with-threads,--with-file-aio, changed core structs),make any old
.soload anywhere — that's what the error message means when it appears despite compat flags.
Practical reading:
--with-compat buys flexibility across feature-flag noise; mirroring
nginx -V buys certainty. Use both; trust the second.
4. Module hygiene
Upgrades are paired events. New nginx binary → every dynamic module needs a rebuild against the new
nginx -V. Automate it: a small
build-<module>.sh script is the template (read the target's flags, build, install, and make it idempotent for re-runs). A runbook line per module; a CI job if you have more than two.
Supply chain is part of your threat model. A
.so executes in your worker with full nginx privilege. For each module know: source repo + commit (ours:
ngx_brotli @ a71f931, recorded in the build log), who built it, and what it links. Treat
load_module some-random.so with the seriousness of
curl | bash — because it is that, with better permissions.
Debug variants exist. Note the
-debug.so siblings in the module dir (njs ships both): built with extra assertions and logging for development. Production runs the regular
.so; Module 16's debug story borrows the same idea for the binary itself.
License audit rides along — most ecosystem modules are BSD-2 like nginx, but a GPL module in your proprietary edge is a conversation with legal, not with ops.
Try it yourself
nginx -T 2>&1 | grep load_module— check your own instance: both module families present, the instance boots with all threeload_modulelines from §1.Re-live the failure: rebuild with a minimal flag set (
./configure --with-compat --add-dynamic-module=...only), swap the.soin,nginx -s reload— meet[emerg] ... not binary compatibleyourself. Then re-run the mirrored-flags build. (The error is the credential; the fix is the skill.)Read a signature in the wild:
strings /usr/lib/nginx/modules/ngx_http_js_module.so | grep -E '^1\.'— the embedded version pair.
Q&A
Q: Can modules be unloaded at runtime? A: No — unloading is impossible: a loaded
.so stays mapped for the life of the process. Adding one, though, is a config edit +
nginx -s reload — the module set is
config-load-time, not boot-time: SIGHUP re-parses the configuration and the new cycle loads the listed
.sos (already-mapped modules are simply reused).
Q: Static vs dynamic — performance difference? A: Function-call indirection at init, effectively none at runtime. Choose by operations (module swap vs rebuild), not speed.
Q: What about
modules/ngx_stream_js_module.so
? A: Same engine, stream-flavored —
js_set inside
stream{}. Lesson 15.4's model transfers; the
r object has session fields instead of HTTP ones.
Q: The module loads but its directives "don't exist"? A: You loaded the wrong flavor (e.g. the
-debug build of a different version) or the directive belongs to the
other module of the pair (brotli filter vs static).
nginx -V + the module's docs settle it.
Takeaways
load_moduleruns at the very top of the config; a module's directives only exist after its line — and unloading is a restart, not a reload.Compatibility = signature match: version + layout-affecting flags.
--with-compatrelaxes feature-flag noise only.The bulletproof recipe — mirror
nginx -Vexactly, plus--add-dynamic-module— turned our[emerg]into a load; keep the receipt where your future self can find it.Module hygiene: paired upgrades per binary bump, recorded provenance per
.so, and the same skepticism for modules you'd apply to piped shell scripts.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.