I’ve lost count of how many times I’ve opened an access log and found the same IP address hammering a login page every few seconds. When that happens, my first instinct isn’t to reach for a firewall rule — it’s to reach for Nginx itself. Nginx has a built-in access control module that lets me block, allow, or restrict traffic at the web server layer, which is often faster to deploy than touching iptables or a cloud security group, especially when I need a fix in the next thirty seconds.
In this guide, I’ll walk through everything I do when I need to block IP addresses in Nginx: blocking a single address, blocking a whole subnet, blocking multiple ranges, combining IP blocking with rate limiting, and even blocking by geographic location using the GeoIP module. I’ll also cover testing, troubleshooting, and the mistakes I’ve personally made that cost me an afternoon of debugging.
Why Block IPs at the Nginx Layer
Before I get into commands, I want to explain why I bother doing this in Nginx instead of just at the firewall. Both approaches have their place:
- Firewall (iptables/nftables/ufw) blocks traffic before it even reaches Nginx, which saves CPU and memory. This is ideal for large-scale or permanent blocks.
- Nginx-level blocking is faster to deploy without root-level firewall changes, works well in containerized environments where I don’t always have iptables access, and lets me combine IP restrictions with URL-specific logic (for example, blocking an IP only from
/wp-adminwhile still letting it browse the rest of the site).
I usually use Nginx blocking for quick, surgical, or path-specific restrictions, and I reserve the firewall for blanket bans of abusive ranges.
Requirements
To follow along, I’m assuming:
- Nginx is already installed (I cover installation in a separate article if you need it)
- You have root or sudo access to the server
- You know the location of your site’s configuration file, typically under
/etc/nginx/sites-available/on Debian/Ubuntu or/etc/nginx/conf.d/on RHEL/CentOS - You’re comfortable reloading Nginx after configuration changes
The deny and allow Directives
The core of IP-based access control in Nginx comes from the ngx_http_access_module, which is compiled into Nginx by default. It gives us two directives:
deny— blocks the specified address or rangeallow— explicitly permits the specified address or range
These directives are evaluated in order, from top to bottom, and Nginx stops at the first match. This ordering trips a lot of people up, so I’ll cover it carefully.
Blocking a Single IP Address
Here’s the simplest case. Say I want to block 203.0.113.45 from reaching my entire site:
server {
listen 80;
server_name example.com;
deny 203.0.113.45;
allow all;
location / {
root /var/www/example.com;
index index.html;
}
}
The order matters here. deny comes first, so that specific IP gets rejected. allow all comes second, permitting everyone else. If I reversed the order, allow all would match first and the deny line would never be reached.
Blocking Multiple IP Addresses
If I have several offending IPs, I just stack the deny lines:
deny 203.0.113.45;
deny 198.51.100.23;
deny 192.0.2.10;
allow all;
Blocking an Entire Subnet (CIDR Notation)
Sometimes a single troublesome IP turns out to be part of a larger block of addresses used by the same attacker or bot network. I can block a whole CIDR range instead of listing individual IPs:
deny 203.0.113.0/24;
allow all;
That /24 blocks all 256 addresses from 203.0.113.0 to 203.0.113.255. I use this a lot when dealing with scraper farms operating out of a single data center’s IP block.
Blocking Everyone Except Specific IPs
Occasionally I want the reverse: lock a location down so only my office or VPN IP can reach it, and everyone else gets denied. This is common for admin panels, staging environments, or internal dashboards.
location /admin {
allow 203.0.113.10; # my office IP
allow 198.51.100.5; # my VPN exit IP
deny all;
}
Here, deny all is the catch-all fallback, and it must come last.
Applying Blocks to Specific Locations Only
I don’t always want to block an IP from the entire site — often I just want to protect a sensitive path like /wp-login.php, /admin, or an API endpoint. I do this by placing the allow/deny block inside a location block instead of the top-level server block:
server {
listen 80;
server_name example.com;
root /var/www/example.com;
location /wp-login.php {
deny 203.0.113.45;
allow all;
}
location / {
index index.html;
}
}
This way, the blocked IP can still browse the rest of the site normally, but it gets a 403 Forbidden the moment it tries to hit the login page.
Using a Separate File for Large Blocklists
If I’m blocking dozens or hundreds of IPs — which happens more often than I’d like when dealing with credential-stuffing attacks — I don’t want a wall of deny lines cluttering my server block. Instead, I keep the list in a separate file and include it:
sudo nano /etc/nginx/blocked_ips.conf
deny 203.0.113.45;
deny 198.51.100.23;
deny 192.0.2.0/24;
deny 45.33.32.0/19;
Then in my server block:
server {
listen 80;
server_name example.com;
include /etc/nginx/blocked_ips.conf;
allow all;
location / {
root /var/www/example.com;
}
}
This keeps my main configuration readable, and it means I can update the blocklist file independently — even generate it with a script — without touching the main site configuration.
Blocking by Country with GeoIP2
Sometimes the traffic I want to block isn’t from a single IP or range, but from an entire country that has no legitimate reason to access my service. For this, I use the ngx_http_geoip2_module, which relies on MaxMind’s GeoIP2 database.
Installing the GeoIP2 Module
On Ubuntu/Debian:
sudo apt update
sudo apt install nginx-module-geoip2 libmaxminddb0 libmaxminddb-dev mmdb-bin
On RHEL/CentOS, the module is usually available as nginx-module-geoip2 through the official Nginx repository.
Downloading the GeoIP Database
MaxMind requires a free account to download the GeoLite2 databases:
sudo mkdir -p /usr/share/GeoIP
sudo curl -o /usr/share/GeoIP/GeoLite2-Country.mmdb \
"https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=YOUR_LICENSE_KEY&suffix=tar.gz"
(Extract the tarball and move the .mmdb file into place — the exact download URL structure requires a valid MaxMind license key.)
Configuring GeoIP2 in nginx.conf
In the http block, load the module and define the mapping:
load_module modules/ngx_http_geoip2_module.so;
http {
geoip2 /usr/share/GeoIP/GeoLite2-Country.mmdb {
auto_reload 5m;
$geoip2_data_country_iso_code country iso_code;
}
map $geoip2_data_country_iso_code $allowed_country {
default yes;
RU no;
CN no;
KP no;
}
server {
listen 80;
server_name example.com;
if ($allowed_country = no) {
return 403;
}
location / {
root /var/www/example.com;
}
}
}
I’ll be honest — I try to avoid if inside location blocks wherever possible because Nginx’s if directive has well-known quirks, but at the server block level for a simple redirect/return like this, it’s a pattern I’ve used safely for years.
Complete Example Configuration
Here’s a fuller example combining several of the techniques above — blocking specific IPs, allowing an admin range, and applying it selectively:
server {
listen 80;
server_name example.com;
root /var/www/example.com;
index index.html;
# Global blocklist
include /etc/nginx/blocked_ips.conf;
allow all;
location /admin {
allow 203.0.113.10;
allow 198.51.100.5;
deny all;
}
location / {
try_files $uri $uri/ =404;
}
}
Testing Your IP Block
After making changes, I always test the syntax first:
sudo nginx -t
If that comes back clean, I reload Nginx without dropping active connections:
sudo systemctl reload nginx
To actually verify the block works, I use curl from a machine with the blocked IP, or I spoof the source using a proxy/VPN:
curl -I http://example.com/
A blocked request should return:
HTTP/1.1 403 Forbidden
If I don’t have access to a second IP to test from, I temporarily add my own current IP to the deny list, test, confirm the 403, then remove it — that’s the safest way to validate the logic without accidentally locking myself out permanently.
Troubleshooting Common Issues
The IP is still getting through. Check the order of allow/deny directives — Nginx processes them top to bottom and stops at the first match. If allow all appears before your deny line, the block never triggers.
Everyone is getting blocked, including legitimate users. This usually happens when deny all is left in a location block without any matching allow line above it, or when a CIDR range is broader than intended. Double-check subnet math with a calculator if you’re not 100% sure — /24 and /16 blocks a very different number of addresses.
Blocking doesn’t work behind a load balancer or CDN. If Nginx sits behind Cloudflare, AWS ALB, or another reverse proxy, $remote_addr will show the proxy’s IP, not the real client IP. I fix this using the real_ip module:
set_real_ip_from 173.245.48.0/20; # example Cloudflare range
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Without this, IP-based blocking is effectively useless behind a proxy, since every request appears to come from the same upstream address.
Configuration test passes but block still doesn’t apply. Make sure you reloaded Nginx after editing the config — nginx -t only validates syntax, it doesn’t apply the change.
Security Considerations
- IP blocking alone won’t stop a determined attacker with access to botnets or rotating proxies. Treat it as one layer, not a complete defense.
- Combine IP blocks with
limit_req(rate limiting) so that even undetected IPs can’t overwhelm your server. - Log blocked attempts separately so you can review patterns later:
location /admin {
deny 203.0.113.45;
allow all;
access_log /var/log/nginx/admin_blocked.log;
}
- Avoid blocking overly broad ranges unless you’re certain of the source — I’ve accidentally blocked a legitimate ISP’s shared NAT range before, which took down access for hundreds of real users sharing that IP.
- Periodically review and prune your blocklist. Stale rules accumulate and become hard to audit.
Performance Tips
- Keep large IP blocklists in a separate
includefile rather than inline in the server block — it’s easier to manage and doesn’t bloat the main config. - For very large blocklists (thousands of entries), consider using the
geomodule instead of long chains ofdenydirectives, sincegeouses a more efficient lookup structure:
geo $blocked {
default 0;
203.0.113.45 1;
198.51.100.0/24 1;
}
server {
if ($blocked) {
return 403;
}
}
- If you’re blocking thousands of IPs permanently, push that work to the firewall (iptables/nftables) instead — it operates at a lower layer and uses far less CPU per request than Nginx-level filtering.
Real-World Use Cases
- Stopping brute-force login attempts on WordPress or custom admin panels after spotting repeated failed logins in the logs.
- Blocking known bad actors identified from threat intelligence feeds or abuse databases like AbuseIPDB.
- Restricting internal tools (Grafana dashboards, phpMyAdmin, Kibana) to office or VPN IP ranges only.
- Geo-fencing a service that’s only legally offered in certain countries.
- Mitigating a live DDoS or scraping incident as a stop-gap measure while I set up more permanent firewall rules upstream.
Best Practices
- Always keep an
allowrule for your own IP or SSH access method before applying broaddeny allrules, so you don’t lock yourself out. - Document why each entry in your blocklist exists — a comment with the date and reason saves confusion six months later.
- Test every change with
nginx -tbefore reloading, every single time, no exceptions. - Use
real_ipcorrectly if you’re behind any proxy or CDN, otherwise your blocks are blocking the wrong address entirely. - Pair Nginx-level IP blocking with fail2ban for automatic, log-driven banning rather than manually editing config files after every incident.
Wrapping Up
IP blocking in Nginx is one of those features that looks trivial on the surface — a couple of lines of config — but it has real depth once you start dealing with proxies, CIDR math, and large-scale blocklists. I use it constantly as a first line of defense, paired with fail2ban for automation and the firewall for anything permanent or large-scale. Once you’re comfortable with allow/deny ordering and the real_ip module, you’ll find it’s one of the fastest tools you have for shutting down abusive traffic without touching a single firewall rule.