Why I Built Rate Limiting and Geo-Blocking Into Caddy
I run several self-hosted services on my home network—things like Vaultwarden, Nextcloud, and a few internal tools. For years, I put everything behind Cloudflare's proxy because it was the easiest way to get DDoS protection, rate limiting, and geo-blocking without thinking too hard about it.
But I started noticing problems. Cloudflare's free tier rate limits are rigid. I couldn't fine-tune them per service. Some legitimate users got blocked during traffic spikes. And honestly, I didn't like routing all my traffic through a third party when I already had a perfectly capable reverse proxy sitting right in front of my services.
I use Caddy for automatic HTTPS and reverse proxying. It's simple, reliable, and I trust it. So I decided to see if I could handle rate limiting and geo-blocking directly in Caddy instead of depending on Cloudflare's WAF.
My Setup: Caddy on Proxmox with Direct Internet Exposure
My Caddy instance runs in an LXC container on Proxmox. It sits at the edge of my network, directly exposed to the internet on ports 80 and 443. Behind it are various Docker containers and VMs running services.
I don't use a separate firewall appliance. My ISP router handles basic port forwarding, and everything else is managed by Caddy and iptables rules on the host.
This means Caddy is the first—and only—layer of defense for HTTP/HTTPS traffic. If I don't handle rate limiting and geo-blocking here, I don't handle it at all.
Rate Limiting: What Actually Worked
Caddy doesn't ship with built-in rate limiting. I found a community module called caddy-ratelimit that adds this functionality. It's not official, but it's actively maintained and does what I need.
Installing it required rebuilding Caddy with xcaddy:
xcaddy build --with github.com/mholt/caddy-ratelimit
I replaced my standard Caddy binary with this custom build. The module lets me define rate limits per route or globally. Here's what I configured for Vaultwarden:
vaultwarden.example.com {
rate_limit {
zone vaultwarden {
key {remote_host}
events 20
window 1m
}
}
reverse_proxy localhost:8080
}
This limits each IP address to 20 requests per minute. If someone exceeds that, Caddy returns a 429 status code.
I set different limits for different services. My public blog doesn't need strict limits, so I allow 100 requests per minute. But for admin panels and APIs, I'm more aggressive—10 requests per minute is usually enough.
The key insight: rate limiting needs to match the service's usage pattern. A static site can handle more traffic. An authentication endpoint should not.
What Didn't Work
My first attempt used a global rate limit across all services. This was a mistake. One legitimate user hitting my blog repeatedly would exhaust the limit and block their access to Vaultwarden too.
I also tried limiting by {http.request.uri} instead of {remote_host}. This was useless. Every request has a different URI, so the limit never triggered.
Another failure: setting the window too short. I tried 10 seconds at first. Legitimate browser behavior—multiple asset requests, parallel connections—hit the limit constantly. One minute turned out to be the sweet spot.
Geo-Blocking: Keeping Out Entire Countries
I don't need users from certain countries accessing my services. Most brute-force attempts on my SSH and web services come from a handful of regions. I wanted to block them outright.
Caddy doesn't have native geo-blocking either, but I found caddy-maxmind-geolocation, which integrates MaxMind's GeoIP2 database.
I signed up for a free MaxMind account and downloaded the GeoLite2-Country database. Then I rebuilt Caddy again:
xcaddy build \
--with github.com/mholt/caddy-ratelimit \
--with github.com/porech/caddy-maxmind-geolocation
I placed the GeoLite2-Country.mmdb file in /etc/caddy/ and configured Caddy to load it:
{
order maxmind_geolocation before rate_limit
}
vaultwarden.example.com {
maxmind_geolocation /etc/caddy/GeoLite2-Country.mmdb
@blocked_countries {
expression {maxmind.country_code} in ["CN", "RU", "KP"]
}
handle @blocked_countries {
abort
}
reverse_proxy localhost:8080
}
This blocks requests from China, Russia, and North Korea. The abort directive drops the connection immediately—no response, no logging, just silence.
I chose these countries based on my access logs. Over 90% of failed login attempts came from IPs in these regions. Blocking them reduced noise significantly.
What Didn't Work
I initially tried blocking individual IPs instead of countries. This was pointless. Attackers rotate IPs constantly. I'd block one, and another would appear within minutes.
I also tried using respond 403 instead of abort. This still sent a response, which meant the attacker knew the service was there. Using abort makes the server look like it doesn't exist at all.
One more mistake: not updating the GeoIP database regularly. MaxMind updates it monthly. I set up a cron job to download the latest version automatically:
0 3 1 * * wget -O /etc/caddy/GeoLite2-Country.mmdb https://download.maxmind.com/app/geoip_download?...
Without this, the database goes stale, and IPs get misclassified.
Combining Both: Layered Protection
Rate limiting and geo-blocking work best together. Geo-blocking removes the bulk of malicious traffic. Rate limiting catches anything that slips through.
For example, my Nextcloud instance is accessible worldwide, so I don't geo-block it. But I do rate-limit it aggressively:
nextcloud.example.com {
rate_limit {
zone nextcloud_global {
key {remote_host}
events 50
window 1m
}
zone nextcloud_login {
key {remote_host}
events 5
window 5m
match {
path /login*
}
}
}
reverse_proxy localhost:8081
}
General browsing allows 50 requests per minute. But the login endpoint only allows 5 attempts every 5 minutes. This stops brute-force attacks without affecting normal usage.
Monitoring: Knowing When Things Break
I log all blocked requests to a separate file:
{
log {
output file /var/log/caddy/blocked.log
format json
level INFO
}
}
I parse this log with a simple Python script that runs daily via cron. It counts blocked IPs and countries, then emails me a summary.
This helps me spot patterns. If I see a sudden spike in blocks from a new country, I investigate. If legitimate users get blocked, I adjust the rate limits.
Performance Impact
I was worried that adding these modules would slow down Caddy. It didn't.
I ran basic benchmarks with ab (ApacheBench) before and after enabling rate limiting and geo-blocking. Response times stayed the same—around 20ms for cached responses.
The GeoIP lookup adds negligible overhead. MaxMind's database is memory-mapped, so lookups are fast. Rate limiting uses in-memory counters, so there's no disk I/O.
The only noticeable impact is memory usage. Caddy's memory footprint increased from about 30MB to 60MB. For my use case, this is irrelevant.
What I Learned
Cloudflare is convenient, but not necessary. If you control your reverse proxy, you can replicate most of Cloudflare's basic protections yourself.
Rate limiting needs tuning. There's no one-size-fits-all value. Monitor your traffic and adjust limits based on real usage.
Geo-blocking is effective but blunt. It works well for personal services where you know your audience. It's not suitable for public-facing sites with global users.
Logging is critical. Without logs, you're flying blind. You won't know if you're blocking legitimate users or if attackers are bypassing your rules.
Modules add complexity. Every time Caddy updates, I need to rebuild my custom binary. This is manageable but annoying. I keep the xcaddy command in a script so I don't forget the exact build flags.
Limitations and Trade-Offs
This setup works for my home lab, but it has limits.
I'm not handling DDoS attacks at the network layer. If someone floods my IP with SYN packets, Caddy won't help. For that, I'd need upstream filtering or a proper firewall appliance.
Geo-blocking breaks if attackers use VPNs or proxies. I've seen this happen—IPs from "safe" countries still attempt brute-force attacks. Rate limiting catches most of these, but it's not perfect.
Custom Caddy builds are a maintenance burden. If a security patch comes out, I need to rebuild immediately. Forgetting to do this could leave me exposed.
Finally, this approach doesn't scale. If I ever need to handle thousands of requests per second, I'd revisit Cloudflare or another CDN. But for self-hosted services with modest traffic, it's more than enough.
Final Thoughts
I've been running this setup for six months. It's stable, effective, and gives me control I didn't have with Cloudflare.
I'm not saying Cloudflare is bad. It's excellent for what it does. But for self-hosted services where you already have a capable reverse proxy, you don't need to route everything through a third party.
Rate limiting and geo-blocking in Caddy took some trial and error, but now it just works. I check the logs occasionally, tweak a limit here and there, and otherwise forget about it.
That's exactly what I wanted.