Tech Expert & Vibe Coder

With 15+ years of experience, I specialize in self-hosting, AI automation, and Vibe Coding – building applications using AI-powered tools like Google Antigravity, Dyad, and Cline. From homelabs to enterprise solutions.

Configuring Caddy rate limiting and IP whitelisting per subdomain to prevent brute force attacks on self-hosted bitwarden

Why I Needed Rate Limiting on My Bitwarden Instance

I run Bitwarden (technically Vaultwarden, the lightweight Rust implementation) on my Proxmox homelab. It’s exposed through Caddy as a reverse proxy, sitting behind my ISP connection with a dynamic DNS setup. The problem is simple: password managers are high-value targets. If someone finds the subdomain, they can hammer the login endpoint with brute force attempts.

I needed a way to limit login attempts per IP without blocking legitimate access from my own devices or trusted networks. Caddy handles my TLS termination and routing, so it made sense to add rate limiting and IP whitelisting directly in the Caddyfile rather than rely on Vaultwarden’s built-in protections alone.

My Actual Setup

Here’s what I’m working with:

  • Vaultwarden running in a Docker container on Proxmox
  • Caddy v2 as the reverse proxy, also in Docker
  • Subdomain: vault.mydomain.com (using Cloudflare DNS)
  • Caddy handles automatic HTTPS via Let’s Encrypt
  • I access Bitwarden from home, work, and occasionally mobile networks

I wanted to:

  1. Rate limit the /api and /identity paths (where login attempts happen)
  2. Whitelist my home IP and a few trusted ranges
  3. Keep everything else accessible but throttled

What Worked

Rate Limiting on Specific Paths

Caddy doesn’t have built-in rate limiting in its core. I had to use the rate_limit module from the Caddy ecosystem. This required building a custom Caddy binary with the module included using xcaddy.

Here’s the command I ran to build it:

xcaddy build --with github.com/mholt/caddy-ratelimit

Once I had the custom binary, I added rate limiting to my Caddyfile like this:

vault.mydomain.com {
    route /api* {
        rate_limit {
            zone dynamic_api {
                key {remote_host}
                events 10
                window 1m
            }
        }
        reverse_proxy vaultwarden:80
    }

    route /identity* {
        rate_limit {
            zone dynamic_identity {
                key {remote_host}
                events 5
                window 1m
            }
        }
        reverse_proxy vaultwarden:80
    }

    reverse_proxy vaultwarden:80
}

This setup limits:

  • /api paths to 10 requests per minute per IP
  • /identity paths (login endpoint) to 5 requests per minute per IP
  • All other paths are unrestricted (like static assets)

The key {remote_host} directive tells Caddy to track requests by the client’s IP address. If someone exceeds the limit, Caddy returns a 429 Too Many Requests response.

IP Whitelisting

I didn’t want rate limiting to affect my own access from home or my work network. Caddy doesn’t have a native IP whitelist directive, so I used the @not_trusted matcher combined with the client_ip module.

First, I defined a matcher for trusted IPs:

@trusted {
    remote_ip 192.168.1.0/24 203.0.113.5
}

Then I applied rate limiting only to non-trusted IPs:

vault.mydomain.com {
    @trusted {
        remote_ip 192.168.1.0/24 203.0.113.5
    }

    route /api* {
        @not_trusted not remote_ip 192.168.1.0/24 203.0.113.5
        rate_limit @not_trusted {
            zone dynamic_api {
                key {remote_host}
                events 10
                window 1m
            }
        }
        reverse_proxy vaultwarden:80
    }

    route /identity* {
        @not_trusted not remote_ip 192.168.1.0/24 203.0.113.5
        rate_limit @not_trusted {
            zone dynamic_identity {
                key {remote_host}
                events 5
                window 1m
            }
        }
        reverse_proxy vaultwarden:80
    }

    reverse_proxy vaultwarden:80
}

This way, my home network (192.168.1.0/24) and my work IP (203.0.113.5) bypass rate limiting entirely. Everyone else gets throttled.

Testing the Setup

I tested this by:

  1. Hitting the login endpoint repeatedly from an external IP using curl
  2. Confirming I got 429 responses after 5 attempts
  3. Verifying my home IP could still log in without issues

I also checked Caddy’s logs to make sure the rate limiter was triggering correctly:

docker logs caddy | grep "rate limit"

The logs showed entries like:

rate limit exceeded for 198.51.100.42 on /identity/connect/token

What Didn’t Work

Initial Attempts Without xcaddy

I initially tried to use Caddy’s native limit_req directive based on some outdated documentation I found. That directive doesn’t exist in Caddy v2. I wasted time debugging syntax errors before realizing I needed the external rate limit module.

Overly Aggressive Limits

My first rate limit was 3 requests per minute on /identity. This broke my mobile app because Bitwarden makes multiple API calls during login (token exchange, sync, etc.). I had to bump it to 5 requests per minute and apply the limit only to the /identity/connect/token path specifically.

Cloudflare Proxy Interference

I run my domain through Cloudflare’s proxy. Initially, Caddy was seeing Cloudflare’s IP addresses instead of the real client IPs. I had to enable trusted_proxies in Caddy to read the CF-Connecting-IP header:

{
    servers {
        trusted_proxies static 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22
    }
}

Without this, my home IP was being seen as a Cloudflare IP, and the whitelist didn’t work.

Key Takeaways

  • Caddy v2 requires external modules for rate limiting. You need xcaddy to build a custom binary.
  • Rate limiting should be path-specific. Don’t throttle static assets or non-sensitive endpoints.
  • IP whitelisting is useful but requires careful testing, especially if you use a CDN or proxy.
  • Always check your logs after implementing rate limits to catch false positives.
  • Bitwarden makes multiple API calls per login. Set limits high enough to avoid breaking legitimate usage.

This setup has been running for three months now. I’ve seen a few 429 responses in the logs from random IPs scanning subdomains, but no issues with my own access. It’s not perfect—someone could still distribute attempts across multiple IPs—but it raises the bar enough to stop basic brute force attacks.

Leave a Comment

Your email address will not be published. Required fields are marked *