Skip to content

LAB 09 – Dynamic modules & njs scripting

Story: Security wants the Server header to say nothing about the software, and product wants A/B buckets computed at the edge. Neither is possible with stock directives – time for modules.

Duration ~40 min (module build ≈ 3–5 min)
You edit env/build/edge-modules/Dockerfile, env/.env, env/nginx/modules-enabled/, env/nginx/njs/, conf.d/
Solution solutions/LAB09/ (or ./bin/ngx catchup 9)

Part A – What is in my NGINX?

docker compose -f env/docker-compose.yml exec edge nginx -V 2>&1 | tr ' ' '\n' | grep -E '^--with|^--add'
docker compose -f env/docker-compose.yml exec edge ls /usr/lib/nginx/modules/
  • --with-http_v3_module, --with-stream, … = static modules compiled into the binary.
  • *.so files = dynamic modules, loaded with load_module in the main context.
  • --with-compat = third-party dynamic modules can be built for this exact binary.

Part B – njs: JavaScript inside NGINX

njs ships with the official image (ngx_http_js_module.so).

  1. Load the module – env/nginx/modules-enabled/10-njs.conf:
load_module modules/ngx_http_js_module.so;

(nginx.conf includes modules-enabled/*.conf in the main context.)

  1. env/nginx/njs/hello.js:
function hello(r) {
    r.headersOut['Content-Type'] = 'application/json';
    r.return(200, JSON.stringify({
        message: 'Hello from njs',
        method: r.method,
        uri: r.uri,
        client: r.remoteAddress,
        bucket: abBucket(r),
        ts: new Date().toISOString()
    }) + '\n');
}

function abBucket(r) {
    const id = r.headersIn['X-User'] || r.remoteAddress;
    let h = 0;
    for (let i = 0; i < id.length; i++) {
        h = (h * 31 + id.charCodeAt(i)) >>> 0;
    }
    return (h % 2) ? 'B' : 'A';
}

export default { hello, abBucket };
  1. env/nginx/conf.d/06-njs.conf:
js_path   /etc/nginx/njs/;
js_import main from hello.js;
js_set    $ab_bucket main.abBucket;
  1. In the shop HTTPS server:
    location = /njs/hello {
        js_content main.hello;
    }
  1. Test:
./bin/ngx reload
curl -s https://shop.localtest.me/njs/hello | jq .
for u in alice bob carol dave; do curl -s -H "X-User: $u" https://shop.localtest.me/njs/hello | jq -r '"\(.bucket)"'; done

✅ Checkpoint: JSON generated by JavaScript; every user gets a stable bucket.

Your turn: pass the bucket to the API: proxy_set_header X-AB-Bucket $ab_bucket; in the api location /, then check whoami's output.

Part C – Build a third-party dynamic module

The headers-more module can rewrite or remove any response header (including Server). It is not in the official image, so we build it – for the exact NGINX version of the image, with --with-compat.

  1. Read env/build/edge-modules/Dockerfile – a multi-stage build:
  2. stage 1 starts from the same nginx image, downloads the matching source ($NGINX_VERSION), runs ./configure --with-compat --add-dynamic-module=... and make modules (only the module – not the whole server),
  3. stage 2 copies the .so into a clean nginx image.
  4. Build it:
cd env
docker build -t pixelshop/edge:modules --build-arg NGINX_TAG=$(grep ^NGINX_TAG .env | cut -d= -f2) build/edge-modules
docker run --rm pixelshop/edge:modules ls /usr/lib/nginx/modules | grep headers_more
  1. Switch the edge to the new image – in env/.env set EDGE_IMAGE=pixelshop/edge:modules, then:
docker compose up -d edge      # recreates the container with the new image
cd ..
  1. Load and use the module:
echo 'load_module modules/ngx_http_headers_more_filter_module.so;' > env/nginx/modules-enabled/50-headers-more.conf

env/nginx/conf.d/07-headers-more.conf:

more_set_headers "Server: PixelShop-Edge";
./bin/ngx reload
curl -sI https://shop.localtest.me/ | grep -i ^server

✅ Checkpoint: server: PixelShop-Edge (with server_tokens off alone you would still see server: nginx).

⚠️ A dynamic module must be rebuilt for every NGINX version upgrade – otherwise: module "..." version 1030006 instead of …. Automate the build in CI next to the image.

Bonus

  • Build ngx_brotli the same way (it needs git clone --recurse-submodules and libbrotli-dev) and compare the size of app.js with Accept-Encoding: br vs gzip.
  • njs can also run in stream {} (js_preread), make subrequests (r.subrequest) and call HTTP APIs (ngx.fetch). Sketch a use case for each.
  • Packaged modules from the nginx.org repository: nginx-module-njs, nginx-module-otel, nginx-module-acme, nginx-module-image-filter … – when is a package better than a custom build?

Troubleshooting

Symptom Likely cause
unknown directive "js_import" load_module missing or not in the main context
module ... is not binary compatible built without --with-compat or for a different nginx version
unknown directive "more_set_headers" the edge still runs the stock image – check EDGE_IMAGE and docker compose up -d edge
njs: SyntaxError ./bin/ngx test shows the file and line

Files for this lab: Think-Cube/nginx-labs/labs/LAB09-modules-njs