logo
Limiting Request Body / File Upload Size in Caddy

Limiting Request Body / File Upload Size in Caddy

Dec 23, 2025

Introduction: Why Request Size Limits Matter

Imagine you run a file upload service or API. One day, someone uploads a 50 GB file to your server. Your disk fills up instantly. Your server crashes. All your users can't access the service.

Or worse: A bot makes 1,000 requests, each with a 1 MB payload, consuming all your memory. Your application becomes unresponsive.

Request body size limiting is a critical security feature that protects your server from:

  • Disk exhaustion: Large files filling up your storage

  • Memory exhaustion: Large payloads consuming all RAM

  • Bandwidth waste: Unnecessary data transfer

  • DoS attacks: Attackers deliberately sending huge requests

  • Cost: Expensive bandwidth and storage bills

  • Performance: Server slowdowns from processing large files

Without limits, any client can send unlimited data, and your server must accept it all before processing.

With limits, you control:

  • Maximum file upload size

  • Maximum API request size

  • Maximum body size for form submissions

  • Different limits for different endpoints

Caddy makes implementing these limits trivial through the request_body directive.


Understanding Request Bodies

What is a Request Body?

When a client (browser, API client, etc.) sends data to your server, it comes in an HTTP request. The request has:

  1. Headers: Metadata about the request (Content-Type, Authorization, etc.)

  2. Body: The actual data being sent

Examples of request bodies:

TypeExampleTypical Size
JSON API request{"name":"John","age":30}1-10 KB
Form submissionemail=john@example.com&password=...1-100 KB
Small file uploadPhoto from phone1-5 MB
Large file uploadVideo file100 MB - 1 GB
Malicious requestAttacker sending garbage data1-100 GB

Where the Request Body is Processed

Client sends request with body
    ↓
Caddy receives the request
    ↓
Caddy checks: Is the body size within the limit?
    ↓
YESCaddy accepts body, forwards to your app
NOCaddy rejects request (413 Payload Too Large)
    ↓
Your Node.js app processes the request (if accepted)

Key point: Size checking happens at Caddy level, before it reaches your application. This protects your app from even receiving oversized requests.


Default Behavior (No Limits)

What Happens Without Limits

By default, Caddy has no built-in request body size limit. It will accept requests of any size (limited only by available disk space and RAM).

# Default - no limits
example.com {
    reverse_proxy localhost:3000
}

Problems:

Attacker uploads 100 GB file
    ↓
Caddy accepts all 100 GB into memory
    ↓
Server runs out of RAMServer crashes
    ↓
All legitimate users get "Service Unavailable"

Why You Need Limits

Real-world attacks:

Attack 1: Disk Exhaustion

Attacker runs script:
for i in {1..100}; do
    curl -X POST --data @1gb-file.bin https://example.com/upload
done

Result:
- 100 GB written to disk
- Disk is full
- Application can't write logs or database records
- Service becomes unavailable

Attack 2: Memory Exhaustion

Attacker sends:
POST /api/submit
Content-Length: 100GB
[100 GB of data]

Caddy starts receiving the request
Memory fills up: 25%, 50%, 75%, 100%
Server becomes sluggish
Application timeout

Attack 3: Bandwidth Waste

Attacker or legitimate user misconfigures client:
Client uploads entire hard drive (500 GB)
Your bandwidth bill: $5,000

Attack 4: Slow Clients

Attacker sends data very slowly (1 byte per second)
Caddy has to keep connection open
After 100,000 slow connections
All connection slots are full
Legitimate users can't connect

Setting Limits

Understanding Size Units

Caddy accepts various size units:

UnitMeaningExample
BBytes1024B = 1 KB
KBKilobytes1KB = 1,000 bytes
MBMegabytes1MB = 1,000 KB
GBGigabytes1GB = 1,000 MB
KiBKibibytes (binary)1KiB = 1,024 bytes
MiBMebibytes (binary)1MiB = 1,024 KB

Note: Most people use MB (decimal), but MiB (binary) is technically more accurate for computers.

Quick Reference: Common Limits

Use CaseRecommended LimitReasoning
JSON API request1 MBPlenty for structured data
Form submission5 MBCovers most normal forms
Small file upload10 MBPhotos, documents
Medium file upload100 MBVideos, large files
Large file upload1 GBProfessional media
Very large (streaming)5 GBUse chunked upload instead

Pattern 1: Global Request Body Limit

Apply the same limit to your entire domain.

Protect Against Large File Uploads

Limit entire domain to 10 MB:

example.com {
    request_body {
        max_size 10MB
    }

    reverse_proxy localhost:3000
}

What this does:

User uploads 5 MB file → ✅ Allowed
User uploads 15 MB file → ❌ Rejected (HTTP 413)
API request with 2 MB JSON → ✅ Allowed
Form submission with 500 KB → ✅ Allowed

HTTP Response When Exceeded:

HTTP/1.1 413 Payload Too Large
Content-Type: text/plain

Payload Too Large

Conservative Limit (API Only)

For REST APIs without file uploads, use a strict limit:

api.example.com {
    request_body {
        max_size 1MB
    }

    reverse_proxy localhost:3001
}

Protects against:

  • Accidental large submissions

  • JSON bomb attacks (deeply nested JSON)

  • Bulk operation abuse

Generous Limit (File Server)

For applications that accept file uploads:

upload.example.com {
    request_body {
        max_size 1GB
    }

    reverse_proxy localhost:3002
}

Allows large file uploads while still preventing excessively large ones.

Multiple Domains with Different Limits

# Strict API - no file uploads
api.example.com {
    request_body {
        max_size 1MB
    }
    reverse_proxy localhost:3001
}

# File service - large uploads
files.example.com {
    request_body {
        max_size 100MB
    }
    reverse_proxy localhost:3002
}

# Public website - moderate limit
www.example.com {
    request_body {
        max_size 10MB
    }
    reverse_proxy localhost:3000
}

Pattern 2: Route-Specific Request Body Limits

Only apply limits to certain routes. Different endpoints have different needs.

Protect Upload Endpoint Only

example.com {
    handle_path /upload/* {
        request_body {
            max_size 5MB
        }
        reverse_proxy localhost:3000
    }

    handle {
        reverse_proxy localhost:3000
    }
}

Access behavior:

POST /upload/file → Limited to 5 MB
GET /products → No limit (just gets data)
POST /api/search → No limit (small JSON)
POST /contact → No limit (form is small)

Different Limits for Different Upload Types

example.com {
    # Profile pictures - small files
    handle_path /upload/profile-pic/* {
        request_body {
            max_size 5MB
        }
        reverse_proxy localhost:3000
    }

    # Document uploads - moderate size
    handle_path /upload/documents/* {
        request_body {
            max_size 50MB
        }
        reverse_proxy localhost:3000
    }

    # Video uploads - larger size
    handle_path /upload/videos/* {
        request_body {
            max_size 500MB
        }
        reverse_proxy localhost:3000
    }

    # Everything else - no upload limit
    handle {
        reverse_proxy localhost:3000
    }
}

Usage:

User uploads 3 MB photo → /upload/profile-pic → ✅ Allowed (under 5 MB)
User uploads 10 MB document → /upload/documents → ✅ Allowed (under 50 MB)
User uploads 200 MB video → /upload/videos → ✅ Allowed (under 500 MB)
User uploads 10 MB to /upload/profile-pic → ❌ Rejected (exceeds 5 MB)

Protect APIs by Endpoint

api.example.com {
    # Bulk import - large payloads
    handle_path /api/v1/import/* {
        request_body {
            max_size 100MB
        }
        reverse_proxy localhost:3001
    }

    # Standard API - moderate payloads
    handle_path /api/v1/* {
        request_body {
            max_size 10MB
        }
        reverse_proxy localhost:3001
    }

    # Public endpoints - small payloads
    handle_path /api/public/* {
        request_body {
            max_size 1MB
        }
        reverse_proxy localhost:3001
    }
}

Combine with Other Security

example.com {
    # File upload with multiple protections
    handle_path /upload/* {
        # Limit request size
        request_body {
            max_size 50MB
        }

        # Rate limit uploads
        rate_limit {
            zone uploads {
                key {remote_host}
                events 10
                window 1h
            }
        }

        # Require authentication
        basicauth {
            user $2a$12$abc123...
        }

        reverse_proxy localhost:3000
    }

    handle {
        reverse_proxy localhost:3000
    }
}

Real-World Scenarios

Scenario 1: E-Commerce Platform

shop.example.com {
    # Product images - moderate limit
    handle_path /upload/products/* {
        request_body {
            max_size 10MB
        }
        reverse_proxy localhost:3000
    }

    # User profile pictures - small limit
    handle_path /upload/profile/* {
        request_body {
            max_size 5MB
        }
        reverse_proxy localhost:3000
    }

    # API for mobile app - strict limit
    handle_path /api/* {
        request_body {
            max_size 5MB
        }
        reverse_proxy localhost:3000
    }

    # Checkout form - small limit
    handle_path /checkout {
        request_body {
            max_size 1MB
        }
        reverse_proxy localhost:3000
    }

    # Everything else
    handle {
        reverse_proxy localhost:3000
    }
}

Scenario 2: Document Management System

docs.example.com {
    # Document uploads - large files allowed
    handle_path /api/documents/upload {
        request_body {
            max_size 500MB
        }

        # Require authentication
        basicauth {
            user $2a$12$abc123...
        }

        # Rate limit to prevent abuse
        rate_limit {
            zone uploads {
                key {http.request.header.Authorization}
                events 20
                window 1h
            }
        }

        reverse_proxy localhost:3001
    }

    # API queries - small payloads
    handle_path /api/* {
        request_body {
            max_size 5MB
        }
        reverse_proxy localhost:3001
    }

    handle {
        reverse_proxy localhost:3001
    }
}

Scenario 3: SaaS with Tiered Plans

app.example.com {
    # Free tier - strict limits
    handle_path /api/free/* {
        request_body {
            max_size 1MB
        }
        reverse_proxy localhost:3000
    }

    # Professional tier - moderate limits
    handle_path /api/pro/* {
        request_body {
            max_size 50MB
        }
        reverse_proxy localhost:3000
    }

    # Enterprise tier - generous limits
    handle_path /api/enterprise/* {
        request_body {
            max_size 1GB
        }
        reverse_proxy localhost:3000
    }

    # File upload - depends on plan
    handle_path /upload/* {
        @pro_user {
            header X-Plan professional
        }

        @enterprise_user {
            header X-Plan enterprise
        }

        handle @enterprise_user {
            request_body {
                max_size 1GB
            }
            reverse_proxy localhost:3000
        }

        handle @pro_user {
            request_body {
                max_size 100MB
            }
            reverse_proxy localhost:3000
        }

        # Free tier
        request_body {
            max_size 10MB
        }
        reverse_proxy localhost:3000
    }

    handle {
        reverse_proxy localhost:3000
    }
}

Common Mistakes to Avoid

Mistake 1: Setting Limits Too Small

# ❌ WRONG - Too restrictive
example.com {
    request_body {
        max_size 1KB
    }
    reverse_proxy localhost:3000
}

Problems:

  • Users can't submit normal forms (which are usually > 1 KB)

  • APIs reject legitimate requests

  • Lots of frustrated users

Solution: Set reasonable limits based on your use case:

# ✅ CORRECT
example.com {
    request_body {
        max_size 10MB  # Reasonable default
    }
    reverse_proxy localhost:3000
}

Mistake 2: Setting Limits Too Large

# ❌ WRONG - No real protection
example.com {
    request_body {
        max_size 100GB
    }
    reverse_proxy localhost:3000
}

Problems:

  • Doesn't protect against large file attacks

  • Disk can still fill up

  • Memory can still be exhausted

Solution: Set realistic limits:

# ✅ CORRECT - Based on actual use case
example.com {
    request_body {
        max_size 50MB  # If handling uploads
    }
    reverse_proxy localhost:3000
}

Mistake 3: Forgetting to Limit Upload Endpoints

# ❌ WRONG - Upload endpoint has no limit
example.com {
    request_body {
        max_size 10MB
    }

    handle_path /upload/* {
        reverse_proxy localhost:3000
    }

    handle {
        reverse_proxy localhost:3000
    }
}

The global limit applies, but it might be too strict for uploads or too generous.

Solution: Explicitly set limits for each endpoint:

# ✅ CORRECT
example.com {
    handle_path /upload/* {
        request_body {
            max_size 500MB  # Specific for uploads
        }
        reverse_proxy localhost:3000
    }

    handle {
        request_body {
            max_size 10MB   # Default for others
        }
        reverse_proxy localhost:3000
    }
}

Mistake 4: No Limits on Internal APIs

# ❌ WRONG - Internal API unprotected
internal.example.com {
    reverse_proxy localhost:3000
}

Even internal APIs can be attacked by:

  • Compromised internal services

  • Bugs in other services making oversized requests

  • Intentional abuse by disgruntled employees

Solution: Protect internal endpoints too:

# ✅ CORRECT
internal.example.com {
    request_body {
        max_size 50MB
    }
    reverse_proxy localhost:3000
}

Mistake 5: Not Testing Actual File Sizes

# ❌ WRONG - Set a limit without testing
example.com {
    request_body {
        max_size 5MB
    }
    reverse_proxy localhost:3000
}

You set 5 MB, but users need to upload 8 MB files. They get rejected. Bad UX.

Solution: Test with actual use cases:

# Test uploading a file
curl -F "file=@large-file.bin" https://example.com/upload

# Test with exactly the size limit
dd if=/dev/zero bs=1M count=5 of=test-file.bin
curl -F "file=@test-file.bin" https://example.com/upload

# Test exceeding the limit
dd if=/dev/zero bs=1M count=6 of=test-file-large.bin
curl -F "file=@test-file-large.bin" https://example.com/upload
# Should get 413 Payload Too Large

Mistake 6: Limiting GET Requests

# ❌ WRONG - Limits GET which has no body
example.com {
    handle_path /api/search {
        request_body {
            max_size 1MB
        }
        reverse_proxy localhost:3000
    }
}

GET requests (fetching data) don't have bodies, so this limit does nothing. But it's confusing.

Solution: Only set limits on routes that receive bodies (POST, PUT, PATCH):

# ✅ CORRECT - Only on endpoints with bodies
example.com {
    handle_path /api/search {
        # No limit needed, GET has no body
        reverse_proxy localhost:3000
    }

    handle_path /api/create {
        # Limit POST requests
        request_body {
            max_size 10MB
        }
        reverse_proxy localhost:3000
    }
}

Mistake 7: Not Considering Concurrent Uploads

# ❌ INCOMPLETE - Limit per request, but not total
example.com {
    request_body {
        max_size 500MB
    }
    reverse_proxy localhost:3000
}

Problems:

  • Each user can upload 500 MB

  • If 100 users upload simultaneously, that's 50 GB total

  • Still fills disk

Solution: Combine with rate limiting:

# ✅ CORRECT - Limit individual uploads AND frequency
example.com {
    rate_limit {
        zone uploads {
            key {remote_host}
            events 5          # Max 5 uploads per hour
            window 1h
        }
    }

    handle_path /upload/* {
        request_body {
            max_size 500MB    # Per upload
        }
        reverse_proxy localhost:3000
    }

    handle {
        reverse_proxy localhost:3000
    }
}

Monitoring and Debugging

Logging Oversized Requests

Enable Caddy logging to track rejected requests:

example.com {
    log {
        output stdout
        format json
    }

    request_body {
        max_size 10MB
    }

    reverse_proxy localhost:3000
}

View logs:

# Watch for 413 errors
sudo journalctl -u caddy -f | grep "413"

# Or in JSON
docker logs caddy-container | jq 'select(.status==413)'

Check Request Size Before Upload

In your application, warn users about size limits:

// Check file size before upload
document.getElementById('file-input').addEventListener('change', (e) => {
    const file = e.target.files[0];
    const maxSize = 10 * 1024 * 1024; // 10 MB
    
    if (file.size > maxSize) {
        alert(`File too large. Max size: 10 MB, Your file: ${(file.size / 1024 / 1024).toFixed(2)} MB`);
        e.target.value = '';
    }
});

Test Different File Sizes

# Test with 1 MB file
dd if=/dev/urandom bs=1M count=1 of=file-1mb.bin
curl -X POST -F "file=@file-1mb.bin" https://example.com/upload

# Test with 10 MB file
dd if=/dev/urandom bs=1M count=10 of=file-10mb.bin
curl -X POST -F "file=@file-10mb.bin" https://example.com/upload

# Test with 20 MB file (should fail if limit is 10 MB)
dd if=/dev/urandom bs=1M count=20 of=file-20mb.bin
curl -X POST -F "file=@file-20mb.bin" https://example.com/upload
# Should get: 413 Payload Too Large

Handling Large Uploads Properly

When users need to upload files larger than reasonable request limits, use chunked uploads:

Client-Side Chunked Upload

Instead of uploading one large file, break it into smaller chunks:

async function uploadLargeFile(file, chunkSize = 5 * 1024 * 1024) {
    const chunks = Math.ceil(file.size / chunkSize);
    
    for (let i = 0; i < chunks; i++) {
        const start = i * chunkSize;
        const end = Math.min(start + chunkSize, file.size);
        const chunk = file.slice(start, end);
        
        const formData = new FormData();
        formData.append('chunk', chunk);
        formData.append('chunkIndex', i);
        formData.append('totalChunks', chunks);
        formData.append('fileId', uniqueFileId);
        
        const response = await fetch('/api/upload-chunk', {
            method: 'POST',
            body: formData
        });
        
        if (!response.ok) {
            throw new Error(`Chunk ${i} failed`);
        }
    }
    
    // All chunks uploaded, tell server to assemble
    await fetch('/api/upload-complete', {
        method: 'POST',
        body: JSON.stringify({ fileId: uniqueFileId })
    });
}

Server-Side Chunked Upload Handler

example.com {
    # Chunk upload endpoint - small chunks
    handle_path /api/upload-chunk {
        request_body {
            max_size 10MB  # Each chunk max 10 MB
        }
        reverse_proxy localhost:3000
    }

    # Finalize endpoint - no body needed
    handle_path /api/upload-complete {
        request_body {
            max_size 1MB
        }
        reverse_proxy localhost:3000
    }
}

Benefits of chunked upload:

  • Users can upload files of any size (limited by disk, not request size)

  • Resume capability (re-upload failed chunks, not whole file)

  • Better progress indication

  • Server can validate each chunk

  • No need to increase request limits excessively


Complete Production-Ready Examples

Full-Featured Application

example.com {
    log {
        output stdout
        format json
    }

    # Profile picture upload - small
    handle_path /api/users/profile-picture {
        request_body {
            max_size 5MB
        }

        rate_limit {
            zone profile_pic {
                key {http.request.header.Authorization}
                events 10
                window 1d
            }
        }

        basicauth {
            user $2a$12$abc123...
        }

        reverse_proxy localhost:3000
    }

    # Document upload - medium
    handle_path /api/documents/upload {
        request_body {
            max_size 100MB
        }

        rate_limit {
            zone doc_upload {
                key {http.request.header.Authorization}
                events 20
                window 1d
            }
        }

        basicauth {
            user $2a$12$abc123...
        }

        reverse_proxy localhost:3000
    }

    # Chunk upload for large files
    handle_path /api/files/chunk {
        request_body {
            max_size 10MB
        }

        rate_limit {
            zone chunk_upload {
                key {http.request.header.Authorization}
                events 1000
                window 1h
            }
        }

        basicauth {
            user $2a$12$abc123...
        }

        reverse_proxy localhost:3000
    }

    # API requests - strict limit
    handle_path /api/* {
        request_body {
            max_size 5MB
        }

        rate_limit {
            zone api {
                key {remote_host}
                events 1000
                window 1m
            }
        }

        reverse_proxy localhost:3000
    }

    # Public site
    handle {
        request_body {
            max_size 1MB
        }

        reverse_proxy localhost:3000
    }
}

Multi-Service Setup

# API Service - strict limits
api.example.com {
    request_body {
        max_size 5MB
    }
    reverse_proxy localhost:3001
}

# File Upload Service - generous limits
upload.example.com {
    request_body {
        max_size 500MB
    }

    basicauth {
        uploader $2a$12$abc123...
    }

    reverse_proxy localhost:3002
}

# WebSocket Service - no body (streaming)
ws.example.com {
    reverse_proxy localhost:3003
}

# Public Website - moderate limits
www.example.com {
    request_body {
        max_size 10MB
    }
    reverse_proxy localhost:3000
}

Key Takeaways

  1. Request size limits are essential for protecting against disk/memory exhaustion attacks.

  2. Set realistic limits based on your actual use cases, not too strict or too generous.

  3. Protect specific endpoints where users upload files, with higher limits than API endpoints.

  4. Combine with other security:

    • Rate limiting (prevent concurrent abuse)

    • IP filtering (block known abusers)

    • Authentication (identify users)

  5. Test your limits before deploying to production.

  6. Monitor rejected requests via logs to identify issues or abuse.

  7. For very large files, use chunked uploads instead of increasing request limits excessively.

  8. Document your limits in API documentation so clients know what to expect.

  9. Consider user experience - don't set limits so strict that legitimate use cases fail.

  10. Think about disk space - limit how much data users can store, not just upload size.


Size Limit Quick Reference

Use this as a starting point for your application:

# Minimal API (no uploads)
request_body { max_size 1MB }

# Standard web app
request_body { max_size 10MB }

# File upload service
request_body { max_size 100MB }

# Video service
request_body { max_size 500MB }

# Professional storage
request_body { max_size 1GB }

# Chunked upload per chunk
request_body { max_size 10MB }

Further Reading