Proxy Cache #

Shortening application response times and reducing backend server workload are top priorities in designing high-performance web infrastructure. One of the most effective methods to achieve this is implementing a caching mechanism at the reverse proxy layer. By storing copies of frequently requested responses directly in Caddy, you can serve repeated user requests immediately without contacting the backend server at all, cutting latency from hundreds of milliseconds to under one millisecond.


Caching Architecture: Browser Cache vs Proxy Cache #

To implement a successful caching strategy, you must understand the architectural differences between the two main caching layers:

flowchart TD
    subgraph BrowserCache ["1. BROWSER CACHE (Client Side)"]
        direction LR
        Browser1["Browser"] -->|"HIT"| Mem["Browser Local Memory"]
    end

    subgraph ProxyCache ["2. PROXY CACHE (Server Side)"]
        direction LR
        Browser2["Browser"] --> Caddy["Caddy Proxy (Cache Store)"]
        Caddy -->|"MISS"| Backend["App Backend"]
    end

1. Browser Cache (Client-Side Caching) #

  • Storage Location: The local memory or disk storage of the user’s computer (client).
  • Control: Controlled by the server through response header instructions like Cache-Control.
  • Advantages: Very fast because no data is transmitted over the internet at all.
  • Disadvantages: Only benefits that individual user; the cache can’t be shared with other users.

2. Proxy Cache (Shared / Server-Side Caching) #

  • Storage Location: The Caddy reverse proxy server itself (either in local RAM or a distributed cache database like Redis).
  • Control: Controlled centrally by Caddy configuration and your backend response headers.
  • Advantages: Can be shared across users (shared cache). If User A requests a product page and triggers cache creation in Caddy, then Users B, C, and so on requesting the same page are served directly from Caddy’s cache without burdening your backend application server.

Here’s a view of the data flow in an ideal multi-layer caching infrastructure:

flowchart TD
    User["Client Browser"] -->|"1. Check Local Cache"| BrowserCache{"Is it in the Browser?"}
    
    BrowserCache -- Yes (HIT) --> ServeLocal["Serve Instantly from Browser"]
    BrowserCache -- No (MISS) --> CDN{"2. Check CDN (Cloudflare)"}
    
    CDN -- Yes (HIT) --> ServeCDN["Serve from CDN Edge"]
    CDN -- No (MISS) --> Caddy{"3. Check Caddy Proxy Cache"}
    
    Caddy -- Yes (HIT) --> ServeCaddy["Serve from Caddy Cache"]
    Caddy -- No (MISS) --> Backend["4. Process on Backend Server"]
    
    Backend -->|"Store in Caddy"| Caddy
    Caddy -->|"Store in CDN"| CDN
    CDN -->|"Store in Browser"| User

    style Caddy stroke:#0288d1,stroke-width:2px
    style Backend stroke:#43a047,stroke-width:2px

Static File Caching Mechanism (Built-in) #

Out of the box, Caddy needs no extra configuration to manage browser caching for static file serving (like images, CSS files, JavaScript, and HTML). Caddy uses a combination of Cache-Control headers and the ETag (Entity Tag) content marker system.

# Optimal static file caching configuration
example.com {
    root * /var/www/dist
    
    # 1. Static assets with hashes in their names (Vite / Webpack output)
    # Example: main.a8f2c3.js -> safe to cache forever because the name is unique
    @immutable_assets path_regexp \.[a-f0-9]{8,}\.(js|css|woff2?|png|jpg)$
    header @immutable_assets Cache-Control "public, max-age=31536000, immutable"
    
    # 2. Main HTML files
    # DON'T cache HTML permanently so application changes are picked up immediately
    @html_files path *.html /
    header @html_files Cache-Control "public, no-cache, must-revalidate"
    
    file_server
}

How Does ETag Save Bandwidth? #

An ETag is a unique hash string generated by Caddy based on the file’s size and last modification time on disk.

  1. On the first request, Caddy sends the file along with the ETag: "w-39d2ab8f" header.
  2. The browser stores the file and that ETag in its local cache.
  3. On the second request (after max-age expires or when the user presses refresh), the browser sends the request back including the If-None-Match: "w-39d2ab8f" header.
  4. Caddy reads that header and compares it with the file’s current state on disk. If the file hasn’t changed, Caddy doesn’t resend the file. Caddy only sends an empty response with status code 304 Not Modified. This saves your network bandwidth a lot.

Caching Dynamic Content with the cache-handler Plugin #

By default, standard Caddy doesn’t include a module to cache dynamic responses (like JSON API output) from the reverse proxy. To add this capability, you must use the custom cache-handler plugin (developed by the Caddy community).

1. Compiling Caddy with the Plugin #

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

# Compile Caddy with the cache-handler module
xcaddy build --with github.com/caddyserver/cache-handler

2. Caddyfile Configuration with Local Storage (RAM) #

After compiling, enable the cache module in the global options block and apply it to your reverse proxy blocks:

# Global Options
{
    # Initialize the cache module
    cache {
        # Use local memory storage (RAM)
        # Very fast, but lost if Caddy restarts
    }
}

# Site Block
api.example.com {
    # Apply caching only to certain API paths
    handle /api/v1/public/* {
        cache {
            # Default TTL (Time To Live) if the backend doesn't send Cache-Control
            ttl 10m
            
            # Ignore certain query parameters when determining cache uniqueness (cache key)
            # key_suffix {query.utm_source}
        }
        reverse_proxy localhost:8080
    }
    
    # Other paths are forwarded directly without caching
    handle {
        reverse_proxy localhost:8080
    }
}

3. Production Configuration Using a Redis Cluster #

For large-scale infrastructure with multiple Caddy servers behind a load balancer, local memory (RAM) storage is ineffective because cache data differs between Caddy servers. You must use Redis as the shared cache storage medium (shared cache store).

On the production Redis database, make sure the eviction policy is set to allkeys-lru (Least Recently Used) so Redis automatically removes old, rarely accessed cache when the server’s RAM is full:

# Example redis.conf settings for caching needs
maxmemory 4gb
maxmemory-policy allkeys-lru

Here’s an example multi-container Docker Compose configuration for running a Caddy cluster with a Redis cache:

# docker-compose.yml
version: "3.7"
services:
  caddy:
    image: my-custom-caddy-with-cache:latest
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
    environment:
      - REDIS_PASSWORD=super-secret-pass
    depends_on:
      - redis

  redis:
    image: redis:7-alpine
    command: redis-server --requirepass super-secret-pass --maxmemory 2gb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data

volumes:
  caddy_data:
  redis_data:

Plus the Caddyfile declaration connecting them dynamically:

# Caddyfile
{
    cache {
        # backends {
        #     redis {
        #         host     "redis"
        #         port     6379
        #         password {env.REDIS_PASSWORD}
        #         db       0
        #     }
        # }
    }
}

app.example.com {
    reverse_proxy app:8080 {
        # The proxy cache process is automatically stored in the shared Redis database
    }
}

API and Dynamic Content Caching Strategies #

Storing API data in the cache requires great care. You must not publicly cache a user’s private data (like shopping cart pages or user profile details) because that data could leak to other users.

Here’s a strategy guide for writing Cache-Control headers your backend application sends to Caddy:

  • no-store: Forbids Caddy and the browser from storing the data at all. Mandatory for sensitive financial or real-time data.
  • private: Tells Caddy (as a shared cache) to not cache this data. Only the user’s own private browser may store it. Suitable for user profile pages.
  • public, max-age=60: Allows anyone (browser and Caddy) to cache this data for 60 seconds. Suitable for common product catalog data.
  • stale-while-revalidate=<seconds>: An advanced strategy where Caddy is allowed to serve expired (stale) cache data to users instantly, while in the background Caddy asynchronously queries the backend to refresh the cache data. This eliminates waiting latency for users.
# Setting detailed cache behavior per endpoint
api.example.com {
    # Public Catalog API - 5 minute cache, 1 minute stale tolerance
    handle /api/v1/products* {
        cache {
            ttl 5m
            default_cache_control "public, max-age=300, stale-while-revalidate=60"
        }
        reverse_proxy localhost:8080
    }
    
    # Authentication and Transaction API - DON'T cache
    handle /api/v1/auth/* {
        reverse_proxy localhost:8080
    }
    handle /api/v1/checkout/* {
        reverse_proxy localhost:8080
    }
}

Cache Invalidation Techniques #

“There are only two hard things in Computer Science: cache invalidation and naming things.” — Phil Karlton.

When data in the database changes, you must immediately discard the old cache in Caddy so users don’t see stale data. Here are three methods you can use:

1. Cache Busting via Hash (Static Files) #

This is the standard method for modern frontend applications (Vite, React, Vue). Every time you do a production build, the build tool automatically inserts a unique hash code into the file name (e.g., app.d8b2a3.js). Because the file name changes, the URL changes automatically, forcing browsers and Caddy to load the new file without being blocked by old cache.

2. The Vary Header #

The Vary header tells Caddy to separate cache storage based on the contents of specific request headers sent by the client.

# Caching based on language headers
api.example.com {
    reverse_proxy localhost:8080 {
        # The backend sets Vary: Accept-Language
        # Caddy will separate the cache for Indonesian and English-speaking users
        header_down Vary "Accept-Language"
    }
}

Anti-Pattern: Using Vary: User-Agent #

// ANTI-PATTERN: Storing separate caches based on client browser User-Agent
Vary: User-Agent // DON'T! This triggers extreme fragmentation

// CORRECT: Split mobile/desktop content at the Caddy routing level
// then cache those two versions explicitly

Setting Vary: User-Agent is a common, fatal mistake. Because there are thousands of unique User-Agent string variations on the internet (different browser versions, operating systems, and devices), Caddy is forced to create thousands of separate cache copies for the same page. This destroys the Cache Hit Rate down to near 0% and quickly exhausts the Caddy server’s RAM storing unnecessary duplicated data.

3. Surrogate Keys / Active Purge Invalidation #

If using an advanced cache plugin (like Souin integration or modern cache-handler), you can programmatically remove cache by sending a Purge request through the Caddy admin API when a data change action happens in your application database:

# Command to manually clean a specific URL's cache
curl -X PURGE https://api.example.com/api/v1/products/123

Caddy Integration Behind a CDN (Cloudflare) #

In industry-scale architectures, you usually place a CDN (like Cloudflare or AWS CloudFront) in front of your Caddy server. You must synchronize the cache duration at the CDN and client browser levels using separate headers:

  • max-age: Sets the cache duration in the client browser.
  • s-maxage: Sets the cache duration specifically for shared proxies (like CDNs). Browsers ignore this value.
# CDN synchronization example
example.com {
    # Set the coordinated cache instruction header
    header {
        # Browsers cache 10 minutes, Cloudflare CDN caches 2 hours (7200 seconds)
        Cache-Control "public, max-age=600, s-maxage=7200"
    }
    
    reverse_proxy localhost:3000
}

Mass Purge via Cache Tags (Cloudflare Purge API) #

With Cloudflare, you can tag responses from Caddy using a special tag (Cache-Tag). If there’s a mass data change in a certain category (e.g., discounted products), you just send one purge command based on that tag through the Cloudflare API:

# Purge Cloudflare cache by tag 'electronics-category'
curl -X POST "https://api.cloudflare.com/client/v4/zones/ZONE_ID/purge_cache" \
     -H "Authorization: Bearer CLOUDF...OKEN" \
     -H "Content-Type: application/json" \
     --data '{"tags":["electronics-category"]}'

Cache Performance Monitoring (Hit Rate) #

You can analyze whether your cache configuration is working effectively by checking the HTTP response headers Caddy sends back to the browser using the curl command:

# Check the response headers from Caddy
curl -I https://api.example.com/api/v1/products

Here are the important headers to watch:

  • X-Cache: HIT: Shows the request was served directly from Caddy’s cache within microseconds (success).
  • X-Cache: MISS: The request wasn’t in the cache, so Caddy had to contact the backend to process the data (normal for the first request).
  • X-Cache-Hits: 12: Shows how many times this cache file has been served to other users.
  • Age: 120: The duration (in seconds) since this cache data was first created in Caddy.

Analyzing Cache Hit Rate from JSON Logs #

You can calculate your cache efficiency percentage (Cache Hit Rate) from Caddy’s JSON log files using the jq tool:

# Calculate HIT vs MISS statistics from the Caddy access log
cat /var/log/caddy/access.log | jq -r '.resp_headers["X-Cache"][0] // "BYPASS"' | sort | uniq -c

Example output:

   8420 HIT
   1580 MISS

From the results above, you can calculate: 8420 / (8420 + 1580) = 84.2%. A value above 80% indicates your caching configuration is running very optimally to save backend resources.

Cache Bypass for Debugging #

During application development, sometimes you want to bypass the cache to see backend code changes directly without cleaning the server cache. You can create a conditional rule using a header matcher:

# Debugging cache bypass configuration
api.example.com {
    # If the client sends the custom header X-No-Cache: true
    @bypass_cache header X-No-Cache true
    
    # Forward directly to the backend without touching the cache module
    handle @bypass_cache {
        reverse_proxy localhost:8080
    }
    
    # Normal traffic uses the cache
    handle {
        cache {
            ttl 1h
        }
        reverse_proxy localhost:8080
    }
}

The test command from your computer’s terminal:

# Fetch the latest data directly from the backend, bypassing the cache
curl -H "X-No-Cache: true" https://api.example.com/api/v1/products

Summary #

  • Two Caching Layers: Distinguish between Browser Cache (client-side, fast but private) and Proxy Cache (Caddy server-side, shareable across users).
  • ETag & 304: Caddy automatically manages ETag validation for static files to save network bandwidth usage through 304 Not Modified responses.
  • Plugin Compilation: Dynamic API cache storage requires a custom Caddy compilation including the cache-handler plugin via xcaddy.
  • Data Security: Make sure private/sensitive dynamic data is labeled with Cache-Control: private or no-store to prevent user data leakage.
  • Stale-While-Revalidate: A reliable strategy to serve expired cache data instantly to clients while Caddy updates that data asynchronously in the background.
  • Hit Rate Analysis: Regularly check cache efficiency through JSON logs using the jq tool to ensure the Cache Hit Rate percentage target stays above 80%.

← Previous: Transport   Next: Load Balancer →

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