
Dec 23, 2025
When you deploy a web application, not all traffic is created equal. Some endpoints should only be accessible from your office network. Some admin dashboards should never be accessible from the public internet. Some IP addresses are known to be malicious and should be blocked entirely.
IP filtering is a security technique that allows you to control which devices (identified by their IP address) can access your website or specific parts of it.
Think of it like a nightclub's VIP list:
Whitelist (Allow List): Only people on the VIP list can enter
Blacklist (Block List): Anyone on the "banned" list can't enter, but everyone else can
Route-specific filtering: Some rooms are only for VIPs, but the main floor is open to everyone
Caddy makes implementing these rules simple through a feature called "matchers" that intercept requests before they reach your application.
Before we dive into the configuration, let's understand how to specify IP addresses in Caddy.
203.0.113.10
This refers to one specific device. Every device connected to the internet has a unique IP address (similar to a home address).
Instead of listing every IP individually, you can specify ranges using CIDR notation:
10.0.0.0/8
192.168.0.0/16
172.16.0.0/12
What does /8, /16, /12 mean?
The number after the slash represents how many bits of the IP address are "fixed" (network portion) and how many can vary (host portion).
10.0.0.0/8: The first 8 bits (10) are fixed, the rest (0.0.0) can be anything → covers 10.0.0.0 to 10.255.255.255 (16 million addresses)
192.168.0.0/16: The first 16 bits (192.168) are fixed → covers 192.168.0.0 to 192.168.255.255 (65,536 addresses)
172.16.0.0/12: The first 12 bits are fixed → covers a large private network range
These IP ranges are reserved for private networks and never used on the public internet:
| Range | Size | Common Use |
|---|---|---|
| 10.0.0.0/8 | 16 million IPs | Large corporate networks, data centers |
| 172.16.0.0/12 | 1 million IPs | Medium corporate networks, Docker networks |
| 192.168.0.0/16 | 65,536 IPs | Small office/home networks, most common |
| 127.0.0.1/8 | 256 IPs | Localhost (your own machine) |
Your Node.js application has an admin panel at /admin that only your team should access. Without IP filtering, anyone on the internet could find it (either by guessing the URL or through search engines).
Solution: Only allow the /admin route from your office IP address or your company's VPN.
You have internal APIs that should only be called from your backend servers or monitoring tools, not from the public internet.
Example: A /metrics endpoint that exposes server health information should only be accessible from your monitoring service.
If you notice a specific IP address repeatedly attacking your site, you can block it immediately.
Example: An IP address is flooding your API with requests trying to guess user IDs. Block it to prevent the attack.
Some companies must comply with laws that require them to block access from certain countries. IP filtering is one tool for this.
Example: A game published in the US might need to block IPs from countries with different regulations.
Force users to go through your company VPN before accessing the application.
Example: A company requires all remote employees to connect through a VPN before accessing internal tools.
Caddy uses a feature called matchers to intercept requests. Here's the flow:
Incoming Request
↓
Caddy checks matchers (@allowed_ips, @blocked_ips, etc.)
↓
Does request match?
├─ YES → Execute the handle block
└─ NO → Continue to next rule
↓
If no rules matched → Execute default handler
The key directive is remote_ip, which tells Caddy to examine the IP address of the client making the request.
This is the most restrictive approach: only allow traffic from IPs you explicitly specify. Everything else is blocked.
example.com {
@allowed_ips {
remote_ip 203.0.113.10 198.51.100.25
}
handle @allowed_ips {
reverse_proxy localhost:3000
}
respond "Access denied" 403
}
How this works:
Request arrives from an IP address
Caddy checks: "Is this IP in my allowed list (203.0.113.10 or 198.51.100.25)?"
If YES: Request goes to your Node.js app (reverse_proxy)
If NO: Respond with "Access denied" (HTTP 403 status code)
Real-world example:
myapp.example.com {
@allowed_ips {
remote_ip 192.168.1.100 192.168.1.101 203.45.67.89
}
handle @allowed_ips {
reverse_proxy localhost:3000
}
respond "Access denied" 403
}
Your office has two workstations (192.168.1.100 and 192.168.1.101) and a remote worker on a specific IP (203.45.67.89). Only these devices can access the app.
Instead of listing IPs individually, use CIDR notation:
example.com {
@allowed_ips {
remote_ip 10.0.0.0/8 192.168.1.0/24
}
handle @allowed_ips {
reverse_proxy localhost:3000
}
respond "Access denied" 403
}
What this allows:
10.0.0.0/8: Any IP starting with 10 (company network)
192.168.1.0/24: Any IP from 192.168.1.0 to 192.168.1.255 (small office network)
Many companies use VPNs to allow remote workers secure access:
internal-app.example.com {
@vpn_users {
remote_ip 10.8.0.0/24
}
handle @vpn_users {
reverse_proxy localhost:3000
}
respond "VPN access required" 403
}
Now only users connected to the company VPN (which assigns IPs in the 10.8.0.0/24 range) can access the app.
Provide helpful error messages to blocked users:
admin.example.com {
@allowed_ips {
remote_ip 10.0.0.0/8
}
handle @allowed_ips {
reverse_proxy localhost:3000
}
respond "Admin access is restricted to the internal network. If you're trying to access from outside, please use the VPN." 403
}
This is less restrictive: allow everyone except IPs you explicitly block. Useful for blocking known attackers.
example.com {
@blocked_ips {
remote_ip 192.0.2.50 203.0.113.99
}
handle @blocked_ips {
respond "Access denied" 403
}
reverse_proxy localhost:3000
}
How this works:
Request arrives
Caddy checks: "Is this IP in my blocklist?"
If YES: Respond with 403 Forbidden
If NO: Allow the request through to your Node.js app
Execution order matters: The blacklist rule is checked first. If the IP is blocked, the request never reaches your application, which is more efficient.
Block an entire network range:
example.com {
@blocked_ips {
remote_ip 192.0.2.0/24 203.0.113.0/24
}
handle @blocked_ips {
respond "Your network has been blocked due to suspicious activity" 403
}
reverse_proxy localhost:3000
}
This blocks two entire subnets (256 IPs each).
If you've identified an attacker using a specific IP range:
example.com {
@botnet {
remote_ip 198.51.100.0/24
}
handle @botnet {
respond "Access denied" 403
}
# Log the attack attempt
log {
output stdout
format single_line
}
reverse_proxy localhost:3000
}
Block and log the attack:
example.com {
@attack_ips {
remote_ip 192.0.2.100 203.0.113.200
}
handle @attack_ips {
# Log suspicious access attempts
log {
output stdout
format json
}
respond "Access denied" 403
}
reverse_proxy localhost:3000
}
Only apply IP restrictions to certain routes (URLs). Other routes remain open to everyone.
example.com {
handle_path /admin/* {
@internal {
remote_ip 10.0.0.0/8 192.168.0.0/16
}
handle @internal {
reverse_proxy localhost:3000
}
respond "Admin access requires internal network access" 403
}
handle {
reverse_proxy localhost:3000
}
}
How this works:
If the request path starts with /admin/:
Check if the IP is in the internal network (10.0.0.0/8 or 192.168.0.0/16)
If YES: Allow it
If NO: Respond with 403
For all other paths (/): Allow everyone
Real-world example:
User at office (IP: 10.5.20.15)
├─ Access /admin → ALLOWED (internal IP)
└─ Access /products → ALLOWED (public route)
User on public internet (IP: 203.45.67.89)
├─ Access /admin → BLOCKED (not internal IP)
└─ Access /products → ALLOWED (public route)
Protect several admin routes:
example.com {
handle_path /admin/* {
@internal {
remote_ip 10.0.0.0/8
}
handle @internal {
reverse_proxy localhost:3000
}
respond "Admin panel requires internal access" 403
}
handle_path /api/internal/* {
@internal {
remote_ip 10.0.0.0/8
}
handle @internal {
reverse_proxy localhost:3000
}
respond "Internal API requires internal access" 403
}
handle_path /metrics {
@internal {
remote_ip 10.0.0.0/8 127.0.0.1
}
handle @internal {
reverse_proxy localhost:3000
}
respond "Metrics endpoint requires internal access" 403
}
# Public routes
handle {
reverse_proxy localhost:3000
}
}
This protects:
/admin/* - Admin dashboard
/api/internal/* - Internal APIs
/metrics - Health metrics
All other routes are public.
Different subdomains with different IP restrictions:
# Public website - open to everyone
www.example.com {
reverse_proxy localhost:3000
}
# Admin dashboard - internal only
admin.example.com {
@internal {
remote_ip 10.0.0.0/8
}
handle @internal {
reverse_proxy localhost:3001
}
respond "Admin access requires internal network" 403
}
# API for partner integrations - partners only
api.example.com {
@partners {
remote_ip 203.0.113.0/24 198.51.100.0/24
}
handle @partners {
reverse_proxy localhost:3002
}
respond "Partner API access only" 403
}
Apply both allow and block rules for fine-grained control.
example.com {
# First, block known bad IPs
@blocked_ips {
remote_ip 10.5.20.50 10.5.20.51
}
handle @blocked_ips {
respond "Your IP has been blocked" 403
}
# Then, allow the larger range
@allowed_ips {
remote_ip 10.0.0.0/8
}
handle @allowed_ips {
reverse_proxy localhost:3000
}
# Block everyone else
respond "Access denied" 403
}
Logic:
Check if IP is in blocklist → If yes, block it
Check if IP is in allowlist → If yes, allow it
Block everyone else
This is useful when you have a large trusted network but need to block specific compromised devices within that network.
internal.example.com {
@trusted_ips {
remote_ip 192.168.1.10 192.168.1.11 192.168.1.12
}
@blocked_ips {
remote_ip 192.168.1.99
}
handle @blocked_ips {
respond "Your device is blocked" 403
}
handle @trusted_ips {
reverse_proxy localhost:3000
}
respond "Access denied" 403
}
Only three specific devices (192.168.1.10, .11, .12) can access, even though they're all on the 192.168.1.0/24 network. Device .99 is explicitly blocked.
shop.example.com {
# Admin routes - internal network only
handle_path /admin/* {
@internal {
remote_ip 10.0.0.0/8 203.45.67.0/24
}
handle @internal {
reverse_proxy localhost:3001
}
respond "Admin access restricted" 403
}
# Known bot/crawler IPs
@bad_bots {
remote_ip 192.0.2.50 192.0.2.51 192.0.2.52
}
handle @bad_bots {
log {
output stdout
}
respond "Automated access not allowed" 403
}
# Everything else - public shop
handle {
reverse_proxy localhost:3000
}
}
app.example.com {
# Team members on VPN
@team {
remote_ip 10.8.0.0/24
}
# Specific partner company
@partners {
remote_ip 203.0.113.0/25
}
# Block known attackers
@blocked {
remote_ip 192.0.2.100 192.0.2.101
}
handle @blocked {
respond "Access denied" 403
}
handle @team {
reverse_proxy localhost:3000
}
handle @partners {
reverse_proxy localhost:3000
}
respond "Access requires VPN or partner credentials" 403
}
api.example.com {
# Premium tier - restricted to premium client IPs
handle_path /api/v1/premium/* {
@premium {
remote_ip 198.51.100.0/24
}
handle @premium {
reverse_proxy localhost:3001
}
respond "Premium API requires premium subscription" 403
}
# Standard tier - broader access
handle_path /api/v1/standard/* {
@authorized {
remote_ip 203.0.113.0/24 198.51.100.0/24 172.16.0.0/16
}
handle @authorized {
reverse_proxy localhost:3002
}
respond "Standard API access restricted" 403
}
# Public tier - anyone
handle_path /api/v1/public/* {
reverse_proxy localhost:3003
}
respond "Not found" 404
}
When your traffic goes through a proxy, Caddy might see the proxy's IP, not the real user's IP.
# ❌ WRONG - If you're behind Cloudflare, you'll block Cloudflare's IPs!
example.com {
@allowed {
remote_ip 192.168.1.0/24
}
handle @allowed {
reverse_proxy localhost:3000
}
respond "Access denied" 403
}
Solution: Tell Caddy to look at the X-Forwarded-For header that proxies add:
# ✅ CORRECT
example.com {
@allowed {
header X-Forwarded-For 192.168.1.0/24
}
handle @allowed {
reverse_proxy localhost:3000
}
respond "Access denied" 403
}
Or if using Cloudflare:
# ✅ CORRECT for Cloudflare
example.com {
@allowed {
header CF-Connecting-IP 192.168.1.0/24
}
handle @allowed {
reverse_proxy localhost:3000
}
respond "Access denied" 403
}
# ❌ WRONG - Invalid CIDR
@internal {
remote_ip 10.0.0.0/33
}
CIDR numbers must be between 0-32. A /33 doesn't exist.
Solution: Use valid CIDR ranges:
# ✅ CORRECT
@internal {
remote_ip 10.0.0.0/8
remote_ip 192.168.0.0/16
}
Rules are evaluated in order. If you have a blocking rule after an allowing rule, the blocking rule might never execute.
# ❌ WRONG - Whitelist will match first, blacklist never checked
example.com {
@allow_all {
remote_ip 10.0.0.0/8
}
handle @allow_all {
reverse_proxy localhost:3000
}
@blocked_ips {
remote_ip 10.0.0.50
}
handle @blocked_ips {
respond "Blocked" 403
}
}
Solution: Put blocklists before allowlists:
# ✅ CORRECT
example.com {
@blocked_ips {
remote_ip 10.0.0.50
}
handle @blocked_ips {
respond "Blocked" 403
}
@allow_all {
remote_ip 10.0.0.0/8
}
handle @allow_all {
reverse_proxy localhost:3000
}
}
When you deploy IP filtering, make sure you don't accidentally block yourself!
# ❌ WRONG - If your office IP is 192.168.1.100, you'll be locked out!
example.com {
@internal {
remote_ip 10.0.0.0/8
}
handle @internal {
reverse_proxy localhost:3000
}
respond "Access denied" 403
}
Solution:
Find your real IP address:
curl ifconfig.me
Include it in the allow list before deploying:
@internal {
remote_ip 192.168.1.100 10.0.0.0/8
}
Test from a different IP to verify blocking works:
# Test with a different IP using a proxy or VPN
curl -H "X-Forwarded-For: 203.0.113.1" https://example.com
IP addresses for cloud services, CDNs, and partners change. Your IP filtering rules become outdated.
Solution:
Keep a comment with the date you last reviewed the rules:
# Last reviewed: 2025-01-15
@partner_ips {
remote_ip 203.0.113.0/24 # Partner A's office
remote_ip 198.51.100.0/24 # Partner B's office
}
Subscribe to IP range updates from partners
Regularly audit your rules (monthly or quarterly)
Find your current IP address:
curl ifconfig.me
Output: 203.45.67.89
Using curl with a spoofed IP header:
# Simulate request from blocked IP
curl -H "X-Forwarded-For: 192.0.2.50" https://example.com
# Expected response: 403 Forbidden
# Simulate request from allowed IP
curl -H "X-Forwarded-For: 10.0.0.50" https://example.com
# Expected response: 200 OK (or your app's response)
View Caddy logs to see IP filtering in action:
# If Caddy is running as a service
sudo journalctl -u caddy -f
# Or if running in Docker
docker logs -f caddy-container-name
Look for access logs showing allowed/denied requests.
# Main public site
example.com {
reverse_proxy localhost:3000
}
# Admin dashboard - internal only
admin.example.com {
@internal {
remote_ip 10.0.0.0/8 203.45.67.100
}
handle @internal {
reverse_proxy localhost:3001
}
log {
output stdout
format json
}
respond "Admin access requires internal network access" 403
}
api.example.com {
# Block known attackers first
@attackers {
remote_ip 192.0.2.0/24
}
handle @attackers {
log { output stdout }
respond "Access denied" 403
}
# Premium customers
handle_path /premium/* {
@premium {
remote_ip 198.51.100.0/24
}
handle @premium {
reverse_proxy localhost:3001
}
respond "Premium access required" 403
}
# Standard customers
handle_path /standard/* {
@standard {
remote_ip 203.0.113.0/24 198.51.100.0/24
}
handle @standard {
reverse_proxy localhost:3002
}
respond "Standard access required" 403
}
# Public API
handle_path /public/* {
reverse_proxy localhost:3003
}
respond "Not found" 404
}
IP filtering is a critical security tool for protecting sensitive routes and admin panels.
Understand CIDR notation to efficiently manage IP ranges instead of listing individual IPs.
Use whitelists (allow lists) for maximum security when protecting sensitive resources.
Use blacklists (block lists) for reactive blocking of known attackers.
Order matters: Put blacklists before whitelists so blockers execute first.
Test thoroughly before deploying, especially when using whitelists, to avoid locking yourself out.
Account for proxies - if your traffic goes through a proxy or CDN, use the appropriate headers (X-Forwarded-For, CF-Connecting-IP).
Update regularly - IP addresses change, so review your rules monthly.
Combine with other security - IP filtering is one layer, use it alongside other protections like authentication and authorization.
Monitor and log - Enable logging to track blocked requests and identify attack patterns.

29 Dec 2025
Node.js vs Python: Which is Better for Back-End Development?

25 Dec 2025
Top 5 Animated UI Component Libraries for Frontend Developers

24 Dec 2025
Why Most Modern Apps use Kafka