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)
endTechnical Comparison Table #
To make tactical decisions easier, here’s a characteristic comparison table between internal rewrites and external redirects:
| Analysis Characteristic | Internal Rewrite | External Redirect |
|---|---|---|
| Execution Location | Entirely inside server memory (Caddy) | Client side (Browser responds to the Location header) |
| Network Round-Trips | 1 request-response | At least 2 request-responses |
| Browser Address Bar | Keeps showing the old URL | Changes to show the new URL |
| HTTP Status Code | Generally 200 OK (or the backend’s status) | 301, 302, 307, or 308 statuses |
| SEO Impact | Doesn’t transfer page ranking authority | Transfers PageRank/authority (especially 301) |
| Main Usage Scenarios | Single Page Applications, hiding backend APIs | Domain 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 touser-service:8081. - Requests to
example.com/api/v1/orders/*are forwarded toorder-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
handleblocks to isolate configurations. Using thehandledirective ensures theuri strip_prefixoperation only applies within that route block’s scope. If you writeuri strip_prefixglobally outside ahandleblock, 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):
uri(Including prefix stripping)rewrite(Internal URI rewriting)try_files(Physical file existence evaluation)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
rewriteto quickly replace the entire URI, and useurifor specific text manipulation operations likestrip_prefixorreplace.- SPA Fallback Pattern: Apply
try_files {path} {path}/ /index.htmlto safely support client-side routing on React/Svelte/Vue applications.- Microservices Integration: Always use
uri strip_prefixinsidehandleblocks to discard route path prefixes before forwarding to the backendreverse_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_regexpto capture dynamic URL variables through capturing groups ($1or{re.name.1}) for advanced URL migrations.