Rate Limiting #

On the open internet, your web server constantly faces abuse threats — from repeated password guessing attacks (brute force), data-hunting bot scans (scrapers), to distributed denial of service attacks (Layer 7 DDoS). Rate Limiting is the most important defense line that limits the number of requests a single client can make within a certain time window. By limiting incoming traffic frequency, you not only secure the server’s integrity from collapse, but also maintain fair resource distribution for all legitimate users. We’ll discuss in depth the various rate limiting algorithms, compiling Caddy with the caddy-ratelimit plugin, dynamic per-endpoint and per-user access limit configuration, distributed cluster synchronization using Redis, and alternative defense integration using the Fail2ban utility.


The Urgency and Various Rate Limiting Algorithms #

Before configuring Caddy, you must understand why rate limiting is so crucial and how it works at the logical level. Rate limiting acts as a filter at the application’s entrance gate. Without rate limiting, attackers can flood your database with heavy search queries, paralyze server CPU within seconds, and drastically increase your cloud infrastructure costs (cloud billing).

There are three main algorithms commonly used by rate limiting systems to track and limit requests:

AlgorithmMain Working MethodAdvantagesDisadvantages
Token BucketThe server stores a bucket containing tokens. Each request consumes 1 token. Tokens are refilled constantly at a certain rate.Supports safe request bursting.Slightly more complex to track token capacity in real time.
Leaky BucketRequests enter a holed container. Requests are processed at a fixed rate exiting the container’s hole. If the container is full, new requests are discarded.Guarantees stable traffic flow to the backend.Can slow down responses for legitimate users if the queue is full.
Sliding WindowRequest tracking uses a dynamic moving time window (rolling window). Counts requests within the actual recent time range.Very accurate and has no spike problem at reset boundaries.Requires higher memory consumption to track request timestamps.

Caddy’s rate limiting plugin uses a Sliding Window variation to calculate limits accurately, ensuring no user can exploit exactly at the time reset boundary.


Installing the caddy-ratelimit Plugin #

Caddy is designed with a lean core philosophy. Therefore, rate limiting isn’t included in the standard Caddy distribution package. To enable it, you must use a community-managed third-party plugin: caddy-ratelimit (created by Matt Holt, Caddy’s creator).

You can recompile the Caddy binary including this plugin using the xcaddy utility:

# 1. Compile Caddy with the ratelimit plugin using xcaddy
xcaddy build --with github.com/mholt/caddy-ratelimit

# 2. Verify the build result to ensure the ratelimit module is installed
./caddy list-modules | grep rate
# Expected output: http.handlers.rate_limit

# 3. Replace the system Caddy binary with the newly compiled binary
sudo systemctl stop caddy
sudo cp ./caddy /usr/bin/caddy
sudo systemctl start caddy

Basic Configuration of the rate_limit Directive #

After Caddy is recompiled with the rate limit module, the rate_limit directive automatically becomes available for use in your Caddyfile.

Here’s the basic configuration for limiting requests per client IP address:

# Basic traffic limiting configuration
example.com {
    rate_limit {
        # Define a tracking zone
        zone dynamic_access {
            # Tracking key: Client identity (IP Address)
            key {remote_host}
            
            # Tracking window time range (sliding window)
            window 1m
            
            # Maximum number of requests allowed within the window
            events 100
        }
    }
    
    reverse_proxy localhost:8080
}

In the configuration above:

  • zone: Defines the Caddy internal memory zone name for tracking request status. Zone names must be unique across the entire configuration.
  • key: The client’s unique identification key. The {remote_host} placeholder instructs Caddy to use the client’s IP address.
  • window: Controls the tracking time range (e.g., 1m for 1 minute, or 15m for 15 minutes).
  • events: The maximum number of requests allowed within the window range.

If a client sends the 101st request within less than 1 minute, Caddy blocks that request and immediately returns the 429 Too Many Requests status code without forwarding it to your backend.


Rate Limiting per Specific Endpoint #

In production environments, the rate limiting needs for each page or application route (path) vary greatly. Login paths need very strict security to prevent brute force attacks, while search API paths need moderate limits because database search queries consume lots of CPU resources.

You can combine named matchers with the rate_limit directive to build layered defense policies:

# Layered per-endpoint defense policy
api.example.com {
    # ── ZONE 1: Authentication Endpoint Protection (Very Strict) ──
    @auth_paths path /api/auth/login /api/auth/register /api/auth/forgot-password
    rate_limit @auth_paths {
        zone auth_lock {
            key {remote_host}
            window 15m
            events 5 # Maximum 5 login attempts per 15 minutes
        }
    }

    # ── ZONE 2: Search API Protection (Medium) ──
    @search_path path /api/v1/search
    rate_limit @search_path {
        zone search_lock {
            key {remote_host}
            window 1m
            events 10 # Maximum 10 search queries per minute
        }
    }

    # ── ZONE 3: General API Limit (Loose) ──
    @general_api path /api/v1/*
    rate_limit @general_api {
        zone api_lock {
            key {remote_host}
            window 1m
            events 60 # Maximum 60 requests per minute (average 1 per second)
        }
    }

    reverse_proxy backend-server:3000
}

Through the configuration above, you lock down vulnerable areas from exploitation efficiently without disturbing normal API transactions for other users.


User-Identity-Based Rate Limiting (User-Authenticated Limiting) #

Limiting access only by client IP address ({remote_host}) has a critical weakness when your application serves thousands of users behind a shared NAT network — like office internet, cafes, or mobile ISP networks. Under NAT, all those users appear to have the same public IP address to Caddy.

If one user abuses and triggers the limit, all other users in that office get unfairly blocked too (false positive).

To overcome this, if your application uses a login system, you can limit access based on the User ID or API Key the client sends after successfully passing authentication:

# Rate limiting based on user identity
api.example.com {
    # Assume our backend inserts the X-User-ID header after validating the JWT
    @authenticated_user header X-User-ID *
    @anonymous_user not header X-User-ID *

    # 1. Policy for logged-in users (More Loose)
    rate_limit @authenticated_user {
        zone user_profile {
            # Tracking key using the unique User ID from the header
            key {header.X-User-ID}
            window 1m
            events 1000 # Allow up to 1000 requests per minute
        }
    }

    # 2. Policy for anonymous guests (Strict)
    rate_limit @anonymous_user {
        zone guest_profile {
            # Fall back to using the IP Address as the key
            key {remote_host}
            window 1m
            events 30 # Only 30 requests per minute for anonymous users
        }
    }

    reverse_proxy backend:8080
}

This method ensures that if anonymous users launch brute-force attacks, your authenticated VIP users can still use the application smoothly without obstacles.


Handling Rate Limit HTTP Response Headers #

Telling clients about their remaining request quota is a best practice in REST API design. This helps frontend teams manage their request queues independently and avoid HTTP 429 errors.

Automatically, the caddy-ratelimit plugin includes the following standard response headers on every request:

  • X-RateLimit-Limit: The total number of requests allowed within the time window.
  • X-RateLimit-Remaining: The remaining request quota before being blocked.
  • X-RateLimit-Reset: The Unix timestamp when the request quota resets back to the beginning.
  • Retry-After: (Only sent when HTTP 429 status is triggered) The waiting pause in seconds before the client may try sending requests again.

Client-Side Handling Code (Graceful Throttling) #

Here’s an example API calling function using JavaScript on the client side that reads Caddy’s rate limit headers to automatically slow down requests before reaching the limit:

// client-api.js
// Dynamic rate limit handling on the frontend side

async function requestWithThrottling(url, attempt = 1) {
    try {
        const response = await fetch(url, {
            headers: {
                'Authorization': 'Bearer our-jwt-token'
            }
        });

        // 1. Read the rate limit headers from Caddy
        const remaining = parseInt(response.headers.get('X-RateLimit-Remaining'));
        const resetTime = parseInt(response.headers.get('X-RateLimit-Reset'));

        // 2. Handle the blocked case (HTTP 429) gracefully
        if (response.status === 429) {
            const retryAfter = parseInt(response.headers.get('Retry-After')) || 5;
            console.warn(`✗ Hit the Caddy Rate Limit. Waiting ${retryAfter} seconds before retrying...`);
            
            // Pause execution for the Retry-After duration
            await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
            return requestWithThrottling(url, attempt + 1); // Try again
        }

        // 3. Proactive Mitigation: Slow down requests if the quota is running low (< 10 requests left)
        if (remaining < 10 && remaining > 0) {
            const now = Math.floor(Date.now() / 1000);
            const resetGap = resetTime - now;
            
            // Calculate the optimal delay spread across the remaining quota
            const delay = (resetGap * 1000) / remaining;
            console.log(`! Critical quota (${remaining} requests left). Slowing down requests by ${delay.toFixed(0)}ms.`);
            await new Promise(resolve => setTimeout(resolve, delay));
        }

        return await response.json();
    } catch (error) {
        console.error("Failed to make the API call:", error);
    }
}

// Simulate consecutive request execution
for (let i = 0; i < 20; i++) {
    requestWithThrottling('https://api.example.com/v1/data');
}

Distributed Rate Limiting Using Redis #

When your infrastructure grows using several Caddy server instances behind an external Load Balancer (multi-node cluster), tracking rate limits in each Caddy server’s local memory causes inconsistent data.

Clients can bypass the limit by sending requests spread across different Caddy servers (limit bypass).

For production clusters, you must configure distributed storage using Redis. With Redis, all Caddy instances read and update the rate limit counter status from one same centralized memory:

# Cluster configuration with Redis Storage for distributed Rate Limiting
example.com {
    rate_limit {
        # Configure Redis as the state storage backend
        # (The module is supported by a distributed caddy-ratelimit plugin variation)
        storage redis {
            address  "redis-cluster.internal:6379"
            username "caddy-node"
            password {env.REDIS_PASSWORD}
            db       0
            timeout  5s
        }

        zone cluster_api {
            key    {remote_host}
            window 1m
            events 100
        }
    }

    reverse_proxy node-1:3000 node-2:3000
}

Tactical Comparison: Rate Limiting in Caddy vs Backend #

When designing application architecture, you often face the question of whether to install rate limiting on the web server side (Caddy) or on the backend application code side (like Laravel, Node.js, or Django).

Here’s a comparative trade-off analysis to help you determine the best architecture:

Rate Limiting in Caddy (Proxy Layer):
  ✓ Very Lightweight: Requests are blocked directly at the network edge before reaching the backend.
  ✓ Maximum DDoS Protection: Protects backend CPU, RAM, and thread resources from request floods.
  ✓ Centralized Management: One Caddyfile configuration secures all services behind it.
  ✗ Context Limitation: Cannot distinguish detailed quotas based on user's remaining balance or subscription packages.

Rate Limiting in the Backend Application:
  ✓ Highly Contextual: Can limit access based on roles, monthly quotas, or subscription tiers.
  ✓ Response Flexibility: Can render custom error pages or redirect to upgrade pages.
  ✗ Resource Waste: Malicious requests still enter, burdening the web server, framework, and backend database connections.

Hybrid Approach Recommendation #

For production-level security, you’re strongly recommended to apply a hybrid approach:

  1. First Layer (In Caddy): Set a coarse-grained IP-Address-based rate limit with a fairly loose limit (e.g., 120 requests per minute) to catch spam bot attacks, rough scrapers, and Layer 7 DDoS before burdening the application.
  2. Second Layer (In Backend): Set a fine-grained User ID/Token-based rate limit (e.g., 10,000 requests per month for the Free package) at the application level for business and billing matters.

Plugin-Free Alternative: Fail2ban Integration #

If you don’t want to recompile Caddy with external plugins, you can build abuse protection using Fail2ban on the Linux operating system. Fail2ban works by periodically monitoring Caddy’s access log files, and automatically blocking IP addresses that violate rules at the OS firewall level (using iptables or nftables).

Here are the detailed steps for configuring Fail2ban integration with Caddy:

1. Create a JSON Access Log File in the Caddyfile #

# Enable JSON access logging to be read by Fail2ban
example.com {
    log {
        output file /var/log/caddy/access.log
        format json
    }
    
    reverse_proxy localhost:8080
}

2. Configure the Fail2ban Jail #

Create a new configuration file /etc/fail2ban/jail.d/caddy.conf:

# /etc/fail2ban/jail.d/caddy.conf

[caddy-bad-requests]
enabled  = true
port     = http,https
logpath  = /var/log/caddy/access.log
backend  = auto
# Block an IP if it triggers 20 4xx/5xx error statuses within 1 minute
maxretry = 20
findtime = 60
# IP blocking duration of 1 hour
bantime  = 3600
filter   = caddy-abuse-detector

3. Create the Fail2ban Regex Filter File #

Create a new filter file /etc/fail2ban/filter.d/caddy-abuse-detector.conf to detect HTTP 4xx or 5xx response statuses from Caddy’s JSON log format:

# /etc/fail2ban/filter.d/caddy-abuse-detector.conf

[Definition]
# Regex to extract remote_addr and error status from the Caddy JSON log
failregex = ^.*"remote_addr":"<HOST>:[0-9]+".*"status":(4[0-9]{2}|5[0-9]{2}),.*$
ignoreregex =
# Using Caddy's built-in ISO8601 date pattern
datepattern = %%Y-%%m-%%dT%%H:%%M:%%S

Restart the Fail2ban service to apply your defense rules:

# Restart fail2ban to apply the new jail configuration
sudo systemctl restart fail2ban

# Check the IP blocking status
sudo fail2ban-client status caddy-bad-requests

Upload and Download Speed Limiting (Bandwidth Throttling) #

Besides limiting the number of request events, rate limiting can also be applied to protect the server’s network bandwidth from exploitation by bots uploading or downloading giant files:

# Protecting the file server bandwidth
files.example.com {
    # ── ZONE 1: Limit File Uploads ──
    @upload_route path /api/v1/upload/*
    rate_limit @upload_route {
        zone upload_limit {
            key {remote_host}
            window 1h
            # Only allow a maximum of 5 file upload requests per hour
            events 5
        }
    }

    # ── ZONE 2: Limit Large File Downloads ──
    @download_route path /assets/videos/*
    rate_limit @download_route {
        zone video_limit {
            key {remote_host}
            window 10m
            # Only allow 20 video downloads per 10 minutes
            events 20
        }
    }

    file_server {
        root /var/www/shared-files
    }
}

Summary #

  • Plugin Compilation: The built-in rate limiting feature doesn’t exist in the Caddy core. You must build a custom Caddy binary using the xcaddy utility with the caddy-ratelimit module.
  • Tracking Zone Logic: Rate limiting configuration is managed through the rate_limit block by specifying the zone property, tracking key, time window, and the events limit.
  • Layered Policies: Design layered defenses using named matchers to give very strict limits on login endpoints to prevent brute force.
  • NAT Network Bypass: Use User ID-based identity limiting (from the header token) instead of IP Addresses so access quotas for users behind shared NAT aren’t mutually disruptive.
  • Informative Headers: Caddy automatically includes X-RateLimit-* and Retry-After headers on HTTP responses to guide the frontend in managing call rates.
  • Distributed Cluster: In multi-server Caddy environments, connect the rate limit module to Redis to sync client quota counters in real time.
  • Network Edge Defense: Rate limiting at the Caddy level (proxy layer) is highly recommended to efficiently reject malicious requests before they can burden the backend database.
  • Fail2ban Alternative: Fail2ban can be used as an OS-firewall-level IP blocking solution by parsing Caddy’s JSON access log files.

← Previous: Basic Auth   Next: IP Restriction →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact