JarvIPs β an nginx module for dynamic IP access control using Linux netfilter ipsets as a blacklists/whitelists.
Unlike nginx's built-in allow/deny directives β which require nginx -s reload on every IP list change β nginx-ipset-blocklist checks the ipset kernel table on every request.
You can add or remove IPs from the set and the change takes effect instantly, with zero nginx restarts and zero dropped connections.
- JarvIPs
- Build and install guide
This is the first question everyone asks.
nginx worker processes run as an unprivileged user (www-data, nobody, etc.) and cannot call libipset or touch netfilter directly β that requires root or CAP_NET_ADMIN. Spawning a privileged subprocess on every HTTP request is too slow.
The solution used here is a small root-owned daemon (ipset_test_server) that holds the privileged connection. nginx workers communicate with it over ONC RPC (a lightweight binary IPC mechanism, part of the standard C library) via a local UDP socket. The round-trip is a single UDP packet each way and takes under 1 ms on localhost.
ONC RPC (also called Sun RPC) uses a system service called rpcbind as a "port directory". When ipset_test_server starts, it registers its UDP port with rpcbind. When an nginx worker starts, it asks rpcbind "where is program 0x200000f1?" and gets back the port. After that, all communication is direct, UDP, localhost. rpcbind is a standard Linux service β it is already present on any server that runs NFS or NIS, and it is trivially installed with apt install rpcbind everywhere else.
HTTP request arrives
β
βΌ
nginx master process (root)
β fork
ββββββββ΄ββββββββ
β β ... N worker processes (www-data)
βΌ βΌ
nginx worker nginx worker
β
β on every request that hits a location
β with blacklist/whitelist configured:
β
β [ACCESS phase]
β ngx_ipset_blocklist.c β module logic
β ββ ipset_test.c β RPC client wrapper
β ββ ipset_test_rpc_clnt.c β auto-generated RPC stub
β
β UDP packet (localhost, ~0.1 ms)
β "Is 1.2.3.4 in set 'myblacklist'?"
β
βΌ
ipset_test_server (runs as root)
ipset_test_server.c
ββ ipset_test_rpc_xdr.c β serialisation (auto-generated)
ββ system("ipset test myblacklist 1.2.3.4")
β
βΌ
Linux kernel β netfilter ipset table (in-memory hash)
β
ββ answer: IN_SET / NOT_IN_SET
β
βΌ UDP reply
nginx worker
ββ IPADDR_IN_IPSET β return HTTP 403
ββ IPADDR_NOT_IN_SET β pass request to content handler
ββ RPC error β log warning, pass request (fail-open)
rpcbind (standard system service)
ββ "phone book": maps program IDs to UDP/TCP ports
ββ ipset_test_server registers here at startup
ββ nginx worker asks here for the port, once per worker start`nginx-ipset-blocklist`/
βββ ngx_ipset_blocklist.c nginx module: config parsing, request handler
βββ ipset_test.c RPC client: builds request, calls RPC stub
βββ ipset_test.h public API: init / test / deinit
βββ ipset_test_rpc.h RPC interface definition (auto-generated by rpcgen)
βββ ipset_test_rpc_clnt.c RPC client stub (auto-generated by rpcgen)
βββ ipset_test_rpc_xdr.c XDR serialisation (auto-generated by rpcgen)
βββ ipset_test_server.c RPC daemon: receives queries, calls ipset
βββ Makefile builds ipset_test_server only (no nginx needed)
βββ ipset_test_server.service systemd unit for the daemon
βββ config nginx build system integrationThe three *_rpc_*.{c,h} files were generated with rpcgen from a .x interface definition file and should not be edited manually.
- Blacklist β return HTTP 403 if the client IP is in the named ipset.
- Whitelist β return HTTP 403 if the client IP is not in the named ipset.
- Both can be active simultaneously in the same context; check order is controlled by
ipset_priority(blacklist first by default). - Configurable at
http {},server {}, andlocation {}level. - Child contexts inherit from parents; any level can override or disable with
off. - One ipset name for both address families, or separate names for IPv4 / IPv6.
- Fail-open on RPC errors: logs a warning and passes the request through.
- Works with nginx 1.9.11+ as a dynamic module, or any modern nginx as static.
blacklist "setname"; # one ipset for both IPv4 and IPv6
blacklist "setname4" "setname6"; # separate ipsets per address family
blacklist off; # disable blacklist at this level (not inherited down)
whitelist "setname";
whitelist "setname4" "setname6";
whitelist off;
ipset_priority blacklist; # check blacklist first (default)
ipset_priority whitelist; # check whitelist first β a whitelisted IP
# always passes, regardless of blacklistValid in: http {}, server {}, location {}
Important
blacklist/whitelist with return in the same location {} block.
Nginx processes requests in phases:
- REWRITE Phase (
return,rewrite) - ACCESS Phase (this module,
allow,deny) - CONTENT Phase (
try_files,proxy_pass, static files)
Because return executes in the REWRITE phase, it will answer the request and terminate processing before the module's access check is ever reached. Use try_files or a proxy backend instead:
# WRONG β return fires before the module
location /api/ {
blacklist "bad_ips";
return 200 "ok"; # β this wins, module is bypassed
}
# CORRECT β try_files runs in CONTENT phase, after ACCESS
location /api/ {
blacklist "bad_ips";
try_files /index.html =503; # module runs first, then this
}A context that does not mention blacklist or whitelist inherits its parent's setting. Using off at any level disables that list for the current context and prevents it from being inherited by child contexts.
http { blacklist "global"; } β set at top level
server { ... } β inherits "global"
server {
blacklist off; β disabled for this server
location /a { ... } β also disabled (inherited "off")
location /b { blacklist "x"; } β re-enabled with a different set
}When both blacklist and whitelist are active, the check order is controlled by ipset_priority:
ipset_priority blacklist(default) β blacklist is checked first. A blacklisted IP is denied even if it is also whitelisted. Use this when "deny overrides allow" β for example, to block a compromised corporate server that is still in the whitelist.ipset_priority whitelistβ whitelist is checked first. A whitelisted IP always passes, regardless of the blacklist. Use this when "allow overrides deny" β for example, to guarantee VIP clients are never accidentally blocked.
ipset_priority is inherited from parent contexts the same way blacklist and whitelist are.
http {
blacklist "bad_ips" "bad_ips6";
server {
listen 80;
server_name app.example.com;
# inherits the global blacklist automatically
}
server {
listen 80;
server_name internal.example.com;
blacklist off; # this server is exempt from the global blacklist
}
}server {
listen 443 ssl;
location /admin/ {
whitelist "office_ips"; # only office IPs; everyone else gets 403
try_files $uri $uri/ =404;
}
location / {
# public β no restrictions
}
}Both conditions apply: the IP must not be in the blacklist AND must be in the whitelist. Blacklist is checked first.
location /api/ {
blacklist "known_bots" "known_bots6";
whitelist "api_clients" "api_clients6";
proxy_pass http://backend;
}http {
blacklist "global_bl" "global_bl6"; # active by default everywhere
server {
location /public/ {
blacklist off; # anyone can access this path
whitelist off;
try_files $uri =404;
}
location /secure/ {
blacklist "strict_bl"; # tighter list overrides the global one
whitelist "vpn_users";
proxy_pass http://secure_backend;
}
}
}http {
blacklist "bad_ips";
whitelist "vip_clients";
ipset_priority whitelist; # VIP always pass, even if in blacklist
location /api/ {
ipset_priority blacklist; # stricter: blacklist wins here
}
}This is the main advantage over nginx's built-in allow/deny:
# Block a new IP β takes effect on the very next request
sudo ipset add myblacklist 1.2.3.4
# Unblock
sudo ipset del myblacklist 1.2.3.4
# Block a subnet
sudo ipset add myblacklist 10.0.0.0/8
# View current contents
sudo ipset list myblacklist
# Persist sets across reboots
sudo ipset save > /etc/ipset.rules
# Restore: sudo ipset restore < /etc/ipset.rules
# (add this to a systemd unit or rc.local to auto-restore on boot)
# On Debian/Ubuntu systems, the easiest way to restore ipsets automatically
# on boot is to install the persistence package:
sudo apt install ipset-persistent
sudo netfilter-persistent saveAll three services must be running, in this order:
1. rpcbind β system service; start it with systemctl
2. ipset_test_server β our daemon; must be up before nginx workers fork
3. nginx β workers connect to the daemon during init_process
Important distinction: If ipset_test_server is not running when nginx starts or reloads, the worker processes will fail to initialize the RPC client and will exit immediately with an [emerg] error. Nginx will refuse to start.
However, if the daemon crashes after nginx is already running, the active workers will not crash. They will safely fall back to the "fail-open" state, logging a [warn] and allowing the HTTP requests through until the daemon is restarted.
With make install the provided systemd unit sets Before=nginx.service and After=network.target, so the boot order is handled automatically.
# Request denied by blacklist
[notice] ipset_blacklist: access denied by blacklist "myblacklist"
# Request denied by whitelist (IP was not in the set)
[notice] ipset_blacklist: access denied by whitelist "office_ips" (IP not in set)
# RPC daemon unreachable β request passed through (fail-open)
[warn] ipset_blacklist: RPC error querying blacklist "myblacklist" β request passed through
# Daemon not running when nginx started β worker exits
[emerg] ipset_blacklist: failed to connect to ipset RPC server on localhost- Fail-open: when
ipset_test_serveris unreachable, requests are passed through rather than blocked. This is intentional β a crashed daemon should not take down your site. Monitor the daemon and restart nginx workers if it crashes for an extended period. - Rename or delete an ipset: restart
ipset_test_serverand reload nginx. Changing ipset contents (add/del) never requires any restart. - UDP transport: the default is UDP with a 25-second timeout. For very high request rates with many workers, switching to TCP (
"udp"β"tcp"inipset_test.c) can reduce port exhaustion. returndirective: see the warning in the Configuration section above.
The project produces two independent binaries. Both must be built and running for the module to work.
| What | How to build | Needs nginx source? |
|---|---|---|
ipset_test_server |
plain make |
No |
nginx module (.so or baked in) |
nginx ./configure + make |
Yes |
Build the daemon first β it does not depend on nginx at all. Then build the nginx module.
| Component | Minimum version | Install |
|---|---|---|
| Linux kernel | 3.0+ | β |
| gcc | any modern | apt install build-essential |
| ipset utility | 7.x | apt install ipset |
| rpcbind | any | apt install rpcbind |
| libtirpc | any | apt install libtirpc-dev |
| libpcre3 | any | apt install libpcre3-dev |
| zlib | any | apt install zlib1g-dev |
| libssl | any | apt install libssl-dev |
| nginx source | 1.30.0+ for dynamic; any modern for static | download below |
Install all build dependencies at once:
sudo apt update
sudo apt install build-essential ipset rpcbind libtirpc-dev \
libpcre3-dev zlib1g-dev libssl-devNote
libtirpc vs libnsl: Modern distros (Debian 11+, Ubuntu 22.04+, Kali 2022+) use libtirpc.
The project's config script and Makefile detect which one is present automatically β you do not need to change anything manually.
cd /path/to/nginx-ipset-blocklist
make
# Output: ./ipset_test_serverIf the build succeeds you will see:
Built: ipset_test_server
Run: sudo ./ipset_test_server
(must run before nginx starts)rpcbind must be active before the daemon can register itself:
sudo systemctl enable rpcbind
sudo systemctl start rpcbind
sudo systemctl status rpcbind --no-pager # must show: Active: active (running)sudo make install
# This does:
# cp ipset_test_server /usr/local/sbin/
# cp ipset_test_server.service /etc/systemd/system/
# systemctl daemon-reload
# systemctl enable ipset_test_server
# systemctl start ipset_test_serverVerify the daemon is registered with rpcbind:
rpcinfo -p localhost | grep 536871153
# Expected output β two lines, one for UDP and one for TCP:
# 536871153 1 udp XXXXX
# 536871153 1 tcp XXXXXIf you see those two lines, the daemon is running correctly and nginx workers will be able to connect to it.
# Start in foreground so you can see output
sudo ./ipset_test_server
# Or in background:
sudo ./ipset_test_server &
# Check registration:
rpcinfo -p localhost | grep 536871153Both options (dynamic .so and static) require the nginx source tree.
The source is a build-time dependency only β the resulting binary or .so has no source dependency and can be deployed anywhere.
The nginx source version must match the nginx binary you will run.
# Check the version of your currently installed nginx:
nginx -v
# Example: nginx version: nginx/1.30.1
# Download the matching source:
export NGX_VER=1.30.1
wget https://nginx.org/download/nginx-${NGX_VER}.tar.gz
tar xf nginx-${NGX_VER}.tar.gz
cd nginx-${NGX_VER}Note
If you are building a static module and will replace the system nginx binary anyway, the version just needs to be a recent stable release β it does not have to match anything.
A dynamic module is a .so shared library that nginx loads at startup.
Your existing nginx binary is not modified or replaced. To update the module, replace the .so and run nginx -s reload β no downtime.
Run this inside the nginx source directory:
cd nginx-${NGX_VER}
./configure \
--add-dynamic-module=/path/to/nginx-ipset-blocklist \
--with-compat--with-compat makes the .so ABI-compatible with any nginx built with the same flag, including distro-packaged nginx binaries. Always include it for dynamic modules.
You should see near the end of the output:
adding module in /path/to/nginx-ipset-blocklist
+ ngx_ipset_blocklist was configuredmake modules
# Takes about 10-30 seconds β compiles only the module, not nginx itself
# Output: objs/ngx_ipset_blocklist.songinx -V 2>&1 | grep modules-path
# Example output:
# --modules-path=/usr/lib/nginx/modules
# or
# --modules-path=/usr/share/nginx/modules# Replace the path with what you found in step 3:
sudo cp objs/ngx_ipset_blocklist.so /usr/lib/nginx/modules/
# Verify it landed there:
ls -la /usr/lib/nginx/modules/ | grep ipsetOpen /etc/nginx/nginx.conf and add the following as the very first line, before events {} and http {}:
load_module modules/ngx_ipset_blocklist.so;
events {
...
}
http {
...
}sudo nginx -t
# Must print: configuration file /etc/nginx/nginx.conf test is successful
sudo nginx -s reload
# Graceful reload β no dropped connections, no downtimecd nginx-${NGX_VER}
make modules
sudo cp objs/ngx_ipset_blocklist.so /usr/lib/nginx/modules/
sudo nginx -s reloadThe module is compiled directly into the nginx binary. No .so file, no load_module directive needed. To update the module you must recompile nginx and replace the binary.
Use this option when:
- You are building nginx from scratch anyway.
- You want the simplest possible deployment (single binary, no external files).
- You are building a container image or custom package.
If you are replacing an existing nginx installation, copy its compile flags so you preserve all currently active modules:
nginx -V 2>&1
# Look for the long list of --with-* flags in "configure arguments"cd nginx-${NGX_VER}
./configure \
--add-module=/path/to/nginx-ipset-blocklist \
--prefix=/etc/nginx \
--sbin-path=/usr/sbin/nginx \
--modules-path=/usr/lib/nginx/modules \
--conf-path=/etc/nginx/nginx.conf \
--error-log-path=/var/log/nginx/error.log \
--http-log-path=/var/log/nginx/access.log \
--pid-path=/run/nginx.pid \
--with-http_ssl_module \
--with-http_v2_module \
--with-http_realip_module
# paste any other --with-* flags from nginx -V heremake -j$(nproc)
# Takes 2-5 minutes depending on your CPU
# Output: objs/nginx# Stop nginx, back up the old binary, replace it
sudo systemctl stop nginx
sudo cp /usr/sbin/nginx /usr/sbin/nginx.bak
sudo cp objs/nginx /usr/sbin/nginx
# Verify the new binary works:
sudo nginx -t
# Start nginx
sudo systemctl start nginx
nginx -vNo load_module line is needed in nginx.conf β the module is always present in this binary.
make -j$(nproc)
sudo cp objs/nginx /usr/sbin/nginx
sudo nginx -t
sudo systemctl restart nginx # full restart required when replacing the binaryipsets must exist before nginx starts. If nginx starts and an ipset named in the config does not exist, the first RPC query to that set will fail (logged as a warning) and the request will be passed through.
# Create sets for IPv4
sudo ipset create myblacklist hash:ip family inet
sudo ipset create mywhitelist hash:ip family inet
# Create sets for IPv6
sudo ipset create myblacklist6 hash:ip family inet6
sudo ipset create mywhitelist6 hash:ip family inet6
# Add some IPs
sudo ipset add myblacklist 1.2.3.4
sudo ipset add myblacklist 10.0.0.0/8 # subnets work too
# Persist across reboots
sudo ipset save > /etc/ipset.rules
# To restore on boot, add this to a systemd unit or /etc/rc.local:
# ipset restore < /etc/ipset.rulesCreate /etc/nginx/conf.d/ngx_test.conf:
server {
listen 8080;
server_name _;
root /var/www/html;
default_type text/plain;
# Sanity check β no module involved, always 200
location = /ping {
return 200 "pong\n";
}
# Blacklist test β use try_files, NOT return
# (return runs before the access check and would bypass the module)
location = /test {
blacklist "myblacklist" "myblacklist6";
try_files /index.html =503;
}
}sudo mkdir -p /var/www/html
sudo touch /var/www/html/index.html
echo "it works" | sudo tee /var/www/html/index.html
sudo nginx -t && sudo nginx -s reload# Add a second loopback address to simulate a "bad" IP
sudo ip addr add 127.0.0.2/8 dev lo
# Add it to the blacklist
sudo ipset add myblacklist 127.0.0.2
# 1. Sanity: nginx itself works
curl -s -o /dev/null -w "ping: %{http_code}\n" http://127.0.0.1:8080/ping
# Expected: 200
# 2. Clean IP is allowed
curl -s -o /dev/null -w "clean IP: %{http_code}\n" http://127.0.0.1:8080/test
# Expected: 200
# 3. Blacklisted IP is blocked
curl -s -o /dev/null -w "blacklisted: %{http_code}\n" \
--interface 127.0.0.2 http://127.0.0.1:8080/test
# Expected: 403
# 4. Dynamic update β remove from blacklist, NO nginx reload
sudo ipset del myblacklist 127.0.0.2
curl -s -o /dev/null -w "after del: %{http_code}\n" \
--interface 127.0.0.2 http://127.0.0.1:8080/test
# Expected: 200 β instant, no reload needed
# 5. Dynamic update β add back
sudo ipset add myblacklist 127.0.0.2
curl -s -o /dev/null -w "after add: %{http_code}\n" \
--interface 127.0.0.2 http://127.0.0.1:8080/test
# Expected: 403 β instant againsudo tail -f /var/log/nginx/error.log | grep -i ipset
# [notice] ipset_blacklist: access denied by blacklist "myblacklist"sudo ip addr del 127.0.0.2/8 dev lo
sudo ipset del myblacklist 127.0.0.2
sudo rm /etc/nginx/conf.d/ngx_test.conf
sudo nginx -s reloadboot
β
βββΊ rpcbind starts (system service, usually automatic)
β
βββΊ ipset_test_server starts (Before=nginx.service in the .service file)
β ββ registers with rpcbind: "I am 0x200000f1, UDP port XXXXX"
β
βββΊ nginx starts
ββ each worker calls init_ipset_test_clnt()
ββ asks rpcbind: "where is 0x200000f1?"
ββ gets back the UDP port
ββ worker is ready to handle requests- Workers crash on startup:
worker process exited with fatal code 2.
ipset_test_server is not running or rpcbind is not running.
sudo systemctl status rpcbind
sudo systemctl status ipset_test_server
# Fix:
sudo systemctl start rpcbind
sudo systemctl start ipset_test_server
sudo systemctl restart nginx- Module has no effect β all requests return 200 regardless of ipset contents.
You are using return in the same location {} block as blacklist/whitelist.
The return directive runs in the REWRITE phase, before the ACCESS phase where the module runs. Replace return 200 "..." with try_files:
# Wrong:
location /x { blacklist "bl"; return 200 "ok"; }
# Correct:
location /x { blacklist "bl"; try_files $uri =503; }- Build fails:
rpc/rpc.h: No such file or directory.
sudo apt install libtirpc-dev
# The project config script will detect it automatically β no other changes needed- Build fails:
ngx_ipset_blocklist.soABI error when loading.
The nginx source version used to compile the .so does not match the running nginx binary. Recompile using the matching source version (check nginx -v).
rpcinfo -p localhostshows nothing for program 536871153.
The daemon is not running or failed to register. Check:
sudo systemctl status ipset_test_server
sudo journalctl -t ipset_test_server -n 30- Daemon is running but nginx workers still cannot connect.
Check that rpcbind is listening:
ss -ulnp | grep rpcbind # UDP
ss -tlnp | grep rpcbind # TCPAlso verify the daemon's UDP port is reachable from localhost:
rpcinfo -u localhost 536871153 1
# Expected: program 536871153 version 1 ready and waiting