Rewrite #

URL rewriting is one of the most fundamental and important HTTP traffic manipulation techniques in modern web server management. Through rewrite, you can change the path or query parameters of incoming requests internally on the server side before the request is further processed by static file handling modules or forwarded to backend servers. Unlike redirects, which involve direct interaction with the client browser, the rewrite process happens entirely inside Caddy’s memory. This means clients never realize the original URL they requested has been changed behind the scenes. We’ll discuss in depth the architectural difference between internal rewrites and external redirects, explore the use of the rewrite and uri directives, practice path manipulation operations like strip prefix, apply routing patterns for Single Page Applications (SPAs), and compose regular expression (regex) matching logic for complex backend routing scenarios.


The Fundamental Difference Between Internal Rewrites and External Redirects #

To design efficient, search-engine-friendly (SEO) web infrastructure, you must clearly understand when to use an internal rewrite and when to use an external redirect. Choosing wrong between these two methods can waste network bandwidth, add unnecessary user latency, or even break page indexing in search engines.

HTTP Workflow Mechanics #

The main difference between the two lies in where the routing decision is resolved and how the client browser responds to those instructions.

1. External Redirect #

When you apply a redirect, Caddy immediately stops evaluating the current request and sends an HTTP response with a 3xx status code (like 301 Moved Permanently or 302 Found) back to the client browser. This response is always accompanied by a Location header indicating the new URL. The client browser receiving that response automatically makes a new TCP connection and sends a new HTTP GET request to the destination URL. The URL in the browser’s address bar changes to show the new address.

2. Internal Rewrite #

Conversely, when you apply a rewrite, Caddy instantly modifies the request URI in its internal memory and continues the request processing flow (middleware chain) using the new URI. If after the rewrite the route points to a static file or reverse proxy, Caddy fetches that content and serves it directly to the client. The client browser keeps the original URL in its address bar and never knows Caddy changed the route internally. No additional network round-trip occurs.

Let’s look at the interaction flow of both mechanisms in the following sequence diagram:

sequenceDiagram
    participant Browser as "Client Browser"
    participant Caddy as "Caddy Web Server"
    participant Backend as "Backend Application"

    rect rgb(240, 248, 255)
        note over Browser, Caddy: Scenario A: External Redirect (301/302)
        Browser->>Caddy: 1. GET /old-page
        Caddy-->>Browser: 2. HTTP 301 Moved Permanently (Location: /new-page)
        note over Browser: The browser changes the URL in the address bar to /new-page
        Browser->>Caddy: 3. GET /new-page (New Connection)
        Caddy->>Backend: 4. Forward the Request to /new-page
        Backend-->>Caddy: 5. Return the New Page Content
        Caddy-->>Browser: 6. HTTP 200 OK
    end

    rect rgb(245, 245, 245)
        note over Browser, Caddy: Scenario B: Internal Rewrite (Internal)
        Browser->>Caddy: 1. GET /old-page
        note over Caddy: Caddy changes the route internally to /new-page
        Caddy->>Backend: 2. Forward the original request as /new-page
        Backend-->>Caddy: 3. Return the New Page Content
        Caddy-->>Browser: 4. HTTP 200 OK (URL in the browser stays /old-page)
    end

Technical Comparison Table #

To make tactical decisions easier, here’s a characteristic comparison table between internal rewrites and external redirects:

Analysis CharacteristicInternal RewriteExternal Redirect
Execution LocationEntirely inside server memory (Caddy)Client side (Browser responds to the Location header)
Network Round-Trips1 request-responseAt least 2 request-responses
Browser Address BarKeeps showing the old URLChanges to show the new URL
HTTP Status CodeGenerally 200 OK (or the backend’s status)301, 302, 307, or 308 statuses
SEO ImpactDoesn’t transfer page ranking authorityTransfers PageRank/authority (especially 301)
Main Usage ScenariosSingle Page Applications, hiding backend APIsDomain migration, permanent page moves

The rewrite Directive vs the uri Directive #

Caddy provides two main directives for manipulating request URIs: rewrite and uri. Although they look similar because both change access paths, they’re designed for different needs with varying flexibility levels.

1. The rewrite Directive #

The rewrite directive is a high-level instruction used to replace the entire URI (path plus query string) directly. It’s a very efficient shortcut for redirecting routes without requiring complex text manipulation.

The basic syntax of the rewrite directive is:

rewrite [<matcher>] <to>

The <to> variable can contain a new static path, Caddy’s built-in dynamic placeholders, or a combination of both. If you rewrite to a path including new query parameters, Caddy automatically merges the client’s old query parameters so they don’t get lost, unless you explicitly specify to discard them.

2. The uri Directive #

The uri directive provides much more granular, specific control over certain parts of the request URI. Instead of directly replacing the entire URI string like rewrite, the uri directive lets you do specific text transformation operations on the path with several sub-instructions:

  • strip_prefix: Removes a certain prefix from the beginning of the request path if it matches.
  • strip_suffix: Removes a certain suffix from the end of the request path if it matches.
  • replace: Replaces a certain substring inside the path with a new string.
  • path_regexp: Uses regular expressions to match and manipulate complex path patterns.

The basic syntax of the uri directive is:

uri [<matcher>] <sub-directive> <arguments...>

Comparing Code: When to Choose Which? #

Let’s look at the real implementation difference between rewrite and uri through the following Caddyfile example:

# Directive usage comparison example
example.com {
    # ANTI-PATTERN: Using a rewrite regex to remove a prefix
    # This way is inefficient because it forces the regex parser to run for every request
    rewrite * /api/v1{path}
    
    # CORRECT: Using the very fast uri strip_prefix directive
    # Caddy processes this at the binary memory level without regex overhead
    uri strip_prefix /api/v1
}

Path Manipulation Operations #

In production environments, you often need to adjust URL paths to disguise your internal directory structure or to match the input format expected by backend servers. Here are the most frequently used path manipulation techniques using the uri directive.

1. Stripping Route Prefixes (strip_prefix) #

When running microservices, you often group traffic by paths like /api/ or /assets/. However, your backend applications may not expect those prefixes in their internal routing. This is where you use strip_prefix.

# Example: Removing the /service-a prefix before sending to the backend
example.com {
    handle /service-a/* {
        # If the client requests: /service-a/users/list
        # Caddy removes "/service-a"
        uri strip_prefix /service-a
        
        # The backend receives the request as: /users/list
        reverse_proxy localhost:8081
    }
}

2. Stripping File Suffixes (strip_suffix) #

This tactic is useful for achieving clean URLs by hiding original file extensions like .html or .php from client browsers.

# Example: Hiding the .html extension
example.com {
    root * /var/www/html
    
    # If the client requests: /about
    # Caddy tries serving the /about.html file internally
    @html_files file {path}.html
    rewrite @html_files {path}.html
    
    # If the client accesses /about.html directly, we strip the .html suffix
    # so the URL in the browser stays clean without the extension
    @has_html_suffix path *.html
    handle @has_html_suffix {
        uri strip_suffix .html
        # Caddy sends a 301 redirect to force the clean URL
        redir {path}
    }
    
    file_server
}

3. Replacing Certain Substrings (replace) #

You can use the replace sub-directive to do simple string find-and-replace operations on the path without using regular expressions.

# Example: Dynamically changing the API version in the URL
example.com {
    # If the client requests: /api/v2/products
    # The path changes to: /api/v3/products
    uri replace /api/v2/ /api/v3/
    
    reverse_proxy localhost:8080
}

SPA (Single Page Application) Routing Patterns #

Modern frontend applications built with frameworks like React, Svelte, Vue, or Angular rely on client-side routing. That means page-to-page navigation is handled entirely by JavaScript in the client browser without making new page requests to the server.

However, the challenge arises when users refresh their browser on a custom route like https://app.example.com/dashboard/settings. The Caddy server by default looks for a physical file named /dashboard/settings or /dashboard/settings/index.html in the root directory. Because that file doesn’t exist (all page logic is wrapped in a single main index.html file), Caddy returns a 404 Not Found response.

To solve this problem, you must apply a fallback pattern where all non-file route requests are internally redirected to index.html.

The Best Solution Using try_files #

The try_files directive is the most elegant and efficient way to handle SPA scenarios. Caddy checks the given path list sequentially. If the first path matches a physical file existing on disk, Caddy serves it directly. If no file matches, Caddy moves to the next path until the final option, which is rewriting to index.html.

# Highly Recommended Production SPA Pattern
app.example.com {
    root * /var/www/my-spa-app/dist
    
    # Check Paths:
    # 1. Check if a physical file matches {path} (e.g. /css/style.css)
    # 2. Check if a physical directory matches {path}/
    # 3. If neither exists, do an internal rewrite to /index.html
    try_files {path} {path}/ /index.html
    
    # Enable the static file service
    file_server
}

Why Is the try_files Approach Better? #

Let’s compare it with the naive approach beginners often use with error-prone explicit matchers:

# SPA approach comparison example
app.example.com {
    root * /var/www/my-spa-app/dist

    # ANTI-PATTERN: Writing manual matchers for all static asset routes
    # DON'T use this way because every time there's a new asset (e.g. .webp),
    # you must edit your production Caddyfile
    @spa_rules {
        not path /index.html
        not path *.js
        not path *.css
        not path *.png
    }
    rewrite @spa_rules /index.html

    # CORRECT: Using try_files which automatically checks physical file existence
    # regardless of whatever file extensions appear in the future
    try_files {path} /index.html
    
    file_server
}

Strip Prefix with reverse_proxy #

One of the most popular deployment architectures is using Caddy as a single API gateway distributing traffic to various backend microservices based on URL paths.

For example:

  • Requests to example.com/api/v1/users/* are forwarded to user-service:8081.
  • Requests to example.com/api/v1/orders/* are forwarded to order-service:8082.

However, often your backend microservice servers are designed independently and don’t know they’re placed behind the /api/v1/ prefix. If Caddy forwards the /api/v1/users/profile request raw to user-service, the backend returns a 404 error because it only recognizes the /users/profile route.

You must strip that prefix right before the request is sent through reverse_proxy.

Implementation Pattern Using handle and uri Blocks #

# Example: API Gateway with Dynamic Prefix Stripping
example.com {
    # Secure the API Gateway by limiting routes
    handle /api/v1/users/* {
        # 1. Remove the "/api/v1/users" prefix so "/api/v1/users/profile" becomes "/profile"
        uri strip_prefix /api/v1/users
        
        # 2. Send the cleaned request to the user service
        reverse_proxy user-service:8081
    }

    handle /api/v1/orders/* {
        uri strip_prefix /api/v1/orders
        reverse_proxy order-service:8082
    }

    # Root route handling to serve static frontend files
    handle {
        root * /var/www/frontend/dist
        try_files {path} /index.html
        file_server
    }
}

[!IMPORTANT] Use handle blocks to isolate configurations. Using the handle directive ensures the uri strip_prefix operation only applies within that route block’s scope. If you write uri strip_prefix globally outside a handle block, Caddy strips the path for every incoming request — including your frontend static assets — breaking the entire visual appearance of your web application.


Query String Manipulation #

Besides changing the path part, you often need to manipulate the query string (parameters after the ? question mark in the URL) before the request is forwarded to your backend upstream.

1. Adding New Query Parameters #

Caddy automatically preserves the client’s original query string when you do a rewrite. If you want to insert additional parameters, just write them in the new destination path and include the built-in {query} placeholder to merge the old parameters.

# Example: Inserting a source parameter for backend analytics tracking
example.com {
    @search_route path /search
    
    # If the client requests: /search?q=caddy
    # Caddy rewrites it to: /api/search?source=gateway&q=caddy
    rewrite @search_route /api/search?source=gateway&{query}
    
    reverse_proxy api-service:8080
}

2. Clearing All Query Parameters #

If for security or privacy reasons you want to discard all query parameters sent by clients before they reach your backend server, just rewrite to a new path without including the {query} placeholder.

# Example: Blocking query parameter leakage on sensitive report routes
example.com {
    @reports path /reports/download
    
    # If the client tries sending a token via query: /reports/download?token=secret123
    # Caddy internally cuts it to: /internal/reports/download (without query)
    rewrite @reports /internal/reports/download
    
    reverse_proxy backend:8080
}

Regular Expression-Based Rewriting (Regex Rewrite) #

For complex system migration scenarios, you often face situations where the old URL patterns differ greatly from the new URL patterns. For example, moving an old blog structure /posts/2026/06/16/learning-caddy to a new structure /blog/learning-caddy.

You can use regular expressions (regex) to capture certain parts of the old URL using capturing groups and rearrange them into the new URL.

Implementation Using the path_regexp Matcher and rewrite Directive #

Caddy uses Go’s built-in very fast regex library, safe from Regular Expression Denial of Service (ReDoS) attacks.

# Example: Dynamic Blog URL Migration using Regex
example.com {
    # 1. Create a named matcher with a regex filter
    # We create a capturing group (.+) to grab the article title slug
    @old_blog_pattern {
        path_regexp old_blog ^/posts/\d{4}/\d{2}/\d{2}/(.+)$
    }

    # 2. Do the rewrite using the capturing group value
    # We access the first capturing group using the {re.old_blog.1} placeholder
    rewrite @old_blog_pattern /blog/{re.old_blog.1}

    # Caddy's internal evaluation result:
    # Input:  /posts/2026/06/16/learning-caddy
    # Output: /blog/learning-caddy

    file_server { root /var/www/blog }
}

Alternative Writing Using the uri path_regexp Directive #

If you only want to do regex manipulation at the path level without using an external named matcher, you can use the uri path_regexp directive directly inside the route. This produces much more concise Caddyfile code.

# Example: Concise One-Line Regex Rewrite
example.com {
    # Syntax: uri path_regexp <pattern> <replacement>
    # We use the $1 substitution variable to reference the first capturing group
    uri path_regexp ^/posts/\d{4}/\d{2}/\d{2}/(.+)$ /blog/$1
    
    reverse_proxy blog-service:8080
}

Route Evaluation Logic in Caddy #

To avoid misconfigurations, you must understand how Caddy evaluates rewrite and routing instructions in its internal memory. Caddy processes routes based on the built-in directive order, not the order the code lines are written in your Caddyfile.

By default, Caddy evaluates directives in the following order (from highest to lowest priority):

  1. uri (Including prefix stripping)
  2. rewrite (Internal URI rewriting)
  3. try_files (Physical file existence evaluation)
  4. reverse_proxy / file_server (Final content serving)

Let’s look at this route evaluation decision flow in the following flowchart:

flowchart TD
    A["1. HTTP Request Received by Caddy"] --> B{"2. Is there a 'uri' directive?\n(e.g. strip_prefix)"}
    
    B -- "Yes" --> C["Change the Path in Memory\n(e.g. Remove /api/v1)"]
    B -- "No" --> D{"3. Is there a 'rewrite' directive?\n(e.g. regex rewrite / named matcher)"}
    C --> D
    
    D -- "Yes" --> E["Replace the full URI internally\n(Path + Query)"]
    D -- "No" --> F{"4. Is there a 'try_files' directive?"}
    E --> F
    
    F -- "Yes" --> G{"Does the physical file/directory exist on disk?"}
    F -- "No" --> H["5. Forward to the Final Handler\n(reverse_proxy or file_server)"]
    
    G -- "Yes" --> I["Serve the physical file\n(file_server)"]
    G -- "No" --> J["Rewrite to the fallback route\n(e.g. /index.html)"]
    J --> H
    
    H --> K["6. Send the HTTP Response to the Client"]

By understanding the diagram above, you know that if you write rewrite and reverse_proxy in the same server block without route boundaries, Caddy always completes the rewrite process first before handing the request to the reverse_proxy handler.


Summary #

  • Internal vs External: Rewrites happen entirely inside Caddy’s memory without changing the URL in the client browser, while Redirects send HTTP 3xx statuses to the browser to trigger a new connection.
  • Directive Differences: Use rewrite to quickly replace the entire URI, and use uri for specific text manipulation operations like strip_prefix or replace.
  • SPA Fallback Pattern: Apply try_files {path} {path}/ /index.html to safely support client-side routing on React/Svelte/Vue applications.
  • Microservices Integration: Always use uri strip_prefix inside handle blocks to discard route path prefixes before forwarding to the backend reverse_proxy.
  • Query Parameters: Caddy by default merges old query parameters when you do a rewrite; use the {query} placeholder for manual merge control.
  • Regular Expressions: Use path_regexp to capture dynamic URL variables through capturing groups ($1 or {re.name.1}) for advanced URL migrations.

← Previous: Middleware   Next: Redirect →

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