Repository: caddyserver/caddy (language: go)

Issue:
Reusable named routes
Sometimes, there seems to be a need for defining a route once (a list of handlers) and invoking it on multiple different conditions (matchers).

My idea to solve this would be to implement two new handlers:

- First one, let's call it `define` for now, defines a route with a name (key). This should be placed as early as possible in the request handling pipeline so that it's always "defined" when it's needed. This would register itself in a map in the request's context for things later to reference it.
- Second one, let's call it `invoke`, takes a name, does a lookup in the request context to find the defined route by name, then executes the routes as if they were defined in the current location. This `invoke` could be paired with a matcher, of course.

The big benefit here is config reuse. So if you have multiple sites which have the exact same pattern, you could define a route once and then execute it from multiple different places, potentially with different variables defined ahead of time. Also, these routes would only be provisioned once, which is an advantage over the Caddyfile snippets feature which is just fancy copy-paste with some string replacement. This could mean better performance and shared state across sites (e.g. `reverse_proxy` health state, etc).

This could be supported in the Caddyfile as well. The syntax for this is up in the air. You could consider this a "reference" in the programmatic sense, so my immediate thinking is to involve `&` in the syntax. So we could make this kinda an "extension" of Caddyfile snippets, and have the syntax like `&(named-route) { ... }`, and later called with `invoke [<matcher>] <named-route>`.


Discussion / hints:
What are some example use cases that need this functionality?
This is all based on the assumption that I'm understanding this all correctly, but:

I use Caddy for my entire domain, and have a number of services, each being served on their own subdomain. My Caddyfile looks a little something like this:

```
www.mydomain.net {
    header / {
        -Server
        X-Powered-By "Lots of loud music"
    }
    reverse_proxy * localhost:7260
}

id.mydomain.net {
    header / {
        Strict-Transport-Security "max-age=2592000;" # 30 days
        X-Frame-Options "DENY"
        -Server
    }
    reverse_proxy localhost:7261
}

rss.mydomain.net {
    header -Server
    reverse_proxy localhost:7264
}

# contents trimmed
```

In each and every single one of these, I'm removing the `Server` header, and usually applying some other headers, like a comedy `X-Powered-By` or a `Strict-Transport-Security`. Having reusable, named, routes would allow these all to be configured in one place and allow me to stop repeating myself throughout the entire configuration for something that is and will always be the same across every route. I actually stumbled across this issue because I was Googling for a way to make a reusable "function" within my config.
@codemicro Thanks for the feedback. Why doesn't `import` work for you for that use case? What would this feature do for you that snippets do not?
Because I had no idea it existed. Pardon me for clogging up the issue, and thanks for the heads-up!
Ah good :) Thanks for participating!
> What are some example use cases that need this functionality?

We've heard of users dynamically updating their config via the API, adding new sites for their customers on demand. Having pre-defined routes would make this simpler to update, especially when each domain needs slightly different config (e.g. need to add a customer-specific request header to the routes before proxying, so it can't just be added to an existing `host` matcher).

It would also reduce memory usage significantly in those cases with a lot of routes, because only a single instance of the route would be provisioned, and only a single copy of state would be in memory. It would also mean health check state would be shared, so if you have the same upstreams for all the sites, you would avoid having active health checks for each instance (which is awful for performance if you have lots of individual proxy instances, a goroutine per proxy handler firing off health checks etc).
A use-case I'm having right now is that I have to block certain requests based on HTTP headers (like "referer" or "from"), and I have like 24 config files for Caddy ( + the main Caddyfile), one of them is a "redirections" file, doing 56 redirections based on hostname, and I'd like to have **all of them** use the exact same blocking system.

I could use iptables or varnish or other stuff to do that, but having it all plug-and-play in Caddy is probably the best IMO.

Another issue that @francislavoie points and that's really important is related to the workaround I found for my personal issue: I created a [snippet](https://caddyserver.com/docs/caddyfile/concepts#snippets) that sets up the same request matcher & responder, something like this:

```
# Caddyfile

# ...
(mysnippet) {
  @mymatcher {
    header_regexp SomeHeader "..."
  }
  respond @mymatcher "Nope nope" 444 {
    close
  }
}
```

Then I have to `import mysnippet` in **all** my config files.

By doing so, it "copy pastes" the snippet in all configs, so you can imagine that for more than 80 sites, it becomes a bit tedious in terms of memory size... So imagine a shared hosting provider that can have thousands of websites...

If the snippet could be changed to a single route (like my matcher + responder tuple), calling `invoke ...` instead of `import ...` all the time would just call the existing route in memory instead of copy/pasting it, saving memory size.
@Pierstoval To clarify, it sounds like a single handler at the very top of your server routes in the JSON config suffices. That's _way_ better than needing named routes.

At least, if we treat "named routes" as a native JSON config concept.

Perhaps there is some precedent for being able to define HTTP handlers in the _Caddyfile_ that are placed at the top of their servers, like maybe a global sub-option of `servers`, that run before any host matching, or any of the Caddyfile routes are evaluated.
> Perhaps there is some precedent for being able to define HTTP handlers in the Caddyfile that are placed at the top of their servers

But it's not necessarily always at the top of the servers that users might want reusable routes. I gave an example about with `reverse_proxy` with shared health checks. That's not possible to do if you "just put it at the top".
My use-case is to prepend some action to **all** site handlers, but I may at some point need to do some other action for specific sites, and in such case, the JSON part will probably not be enough, because I will have to copy/paste everything in all handlers, or use the same workaround everywhere ( a.k.a snippet at the root Caddyfile, and `import mysnippet` in all other sites config files). It's kinda bad memory-wise.

Another really good point about named routes is DX: having to switch my 20+ files to JSON is bad for users that don't use JSON for their config file, and I will probably have to constantly go back and forth with the documentation to be sure I'm not writing the bad JSON path, don't forget the colons, the brackets etc., while Caddyfile syntax is way simpler to use, has a smoother learning curve than the whole JSON (which I thought was mostly dedicated to consuming the admin API). (and also, I'm managing my server in CLI only, and I use VIM, and writing JSON in VIM is a true torture compared to writing a Caddyfile 😁)

I'm sorry if I'm bringing a strange use-case on the table, maybe I'm not using Caddy the right way, but I just have a config that looks like this on my server:

```
/etc/caddy/
├── Caddyfile
└── vhosts/
    ├── vhost1
    ├── vhost2
    ├── vhost3
    ├── vhost4
    ├── ...
    └── vhostN
```

And the contents of my main Caddyfile is really light:

```
$ cat Caddyfile
{
        #debug
        email my-email@example.com
}

main-server-domain.example.com {
        root * /var/www/html
        file_server
}

(myrule) {
	# my general rule that I'd like any vhost to be able to import
}

import vhosts/*
```

Currently, **all** my vhosts contain `import myrule` in their definition, especially the 50+ redirection rules that I configured, which is really bothersome to implement. I would be okay with this method if it wasn't necessary for all sites, and in such case, I understand that my snippet would be kinda fine.
But as said above, there are other concerns, because here, if I want to use [this "spam blocker"](https://github.com/Stevie-Ray/referrer-spam-blocker), it means that I have to import [this heavy file](https://github.com/Stevie-Ray/referrer-spam-blocker/blob/master/referral-spam.caddy2).

I just did configure this "spam blocker", enabled `admin` and `curl`-ed to the `/config/` endpoint to download the full config, and it contains the copy/pasted snippet 77 times, the outputted config file is 6.1MB and Caddy weighs 446.1MB in memory.
EDIT: I just disabled my global rule, the JSON file is now 33KB (meaning using my block rule makes the JSON config 183 times heavier!), and Caddy weighs 401.3MB in memory. (The previous one is 1.11 times heavier, but it's still 45MB)

I'm pretty certain that having one single "global handler" consisting in a "define" route + a "invoke" statement in all my vhosts would be a very good start to improve memory usage 😉

Interesting discussion so far.

@Pierstoval I imagine your files in the `vhosts` folder are all pretty similar?

I am having a hard time justifying the complexity of self-referencing JSON while I can see simpler, more elegant ways to write these configs with hand-crafted JSON that simply isn't repetitive.

But I hear you, you want to use the Caddyfile.
> @Pierstoval I imagine your files in the `vhosts` folder are all pretty similar?

Nope 😁 Some are, like the PHP/Symfony apps all contain the same customized stuff.

Example with two of my Symfony apps:

```
/etc/caddy/vhosts$ cat corahnrin.pierstoval.com esterenmaps.pierstoval.com

corahnrin.pierstoval.com {
        root * /var/www/corahnrin.pierstoval.com/www/public/
        encode gzip
        file_server
        import blockspam
        php_fastcgi unix//run/php/php8.0-fpm.sock
        tls ...
        log {
                output file /var/www/corahnrin.pierstoval.com/access.log {
                        roll_size 512mb
                        roll_keep_for 720h
                }
        }
}


esterenmaps.pierstoval.com {
        root * /var/www/esterenmaps.pierstoval.com/www/public/
        encode gzip
        import blockspam
        file_server
        php_fastcgi unix//run/php/php8.0-fpm.sock
        tls ...
        log {
                output file /var/www/esterenmaps.pierstoval.com/access.log {
                        roll_size 512mb
                        roll_keep_for 720h
                }
        }
}
```

I could use a snippet and use the first arg as domain name, that's how I organize my apps, and it might maybe simplify stuff, but sometimes I have to customize the PHP version (some projects use 7.4 and some others use 8.1 for example), so the more specific use-cases I have, the more I need abstraction, the more complex it becomes in the end 😅 There's a balance for all of that and it's not easy to find it.

Fix patch:
diff --git a/caddyconfig/caddyfile/parse.go b/caddyconfig/caddyfile/parse.go
index ab84086afc1..64d106256fe 100644
--- a/caddyconfig/caddyfile/parse.go
+++ b/caddyconfig/caddyfile/parse.go
@@ -148,7 +148,6 @@ func (p *parser) begin() error {
 	}
 
 	err := p.addresses()
-
 	if err != nil {
 		return err
 	}
@@ -159,6 +158,25 @@ func (p *parser) begin() error {
 		return nil
 	}
 
+	if ok, name := p.isNamedRoute(); ok {
+		// named routes only have one key, the route name
+		p.block.Keys = []string{name}
+		p.block.IsNamedRoute = true
+
+		// we just need a dummy leading token to ease parsing later
+		nameToken := p.Token()
+		nameToken.Text = name
+
+		// get all the tokens from the block, including the braces
+		tokens, err := p.blockTokens(true)
+		if err != nil {
+			return err
+		}
+		tokens = append([]Token{nameToken}, tokens...)
+		p.block.Segments = []Segment{tokens}
+		return nil
+	}
+
 	if ok, name := p.isSnippet(); ok {
 		if p.definedSnippets == nil {
 			p.definedSnippets = map[string][]Token{}
@@ -167,7 +185,7 @@ func (p *parser) begin() error {
 			return p.Errf("redeclaration of previously declared snippet %s", name)
 		}
 		// consume all tokens til matched close brace
-		tokens, err := p.snippetTokens()
+		tokens, err := p.blockTokens(false)
 		if err != nil {
 			return err
 		}
@@ -576,6 +594,15 @@ func (p *parser) closeCurlyBrace() error {
 	return nil
 }
 
+func (p *parser) isNamedRoute() (bool, string) {
+	keys := p.block.Keys
+	// A named route block is a single key with parens, prefixed with &.
+	if len(keys) == 1 && strings.HasPrefix(keys[0], "&(") && strings.HasSuffix(keys[0], ")") {
+		return true, strings.TrimSuffix(keys[0][2:], ")")
+	}
+	return false, ""
+}
+
 func (p *parser) isSnippet() (bool, string) {
 	keys := p.block.Keys
 	// A snippet block is a single key with parens. Nothing else qualifies.
@@ -586,18 +613,24 @@ func (p *parser) isSnippet() (bool, string) {
 }
 
 // read and store everything in a block for later replay.
-func (p *parser) snippetTokens() ([]Token, error) {
-	// snippet must have curlies.
+func (p *parser) blockTokens(retainCurlies bool) ([]Token, error) {
+	// block must have curlies.
 	err := p.openCurlyBrace()
 	if err != nil {
 		return nil, err
 	}
-	nesting := 1 // count our own nesting in snippets
+	nesting := 1 // count our own nesting
 	tokens := []Token{}
+	if retainCurlies {
+		tokens = append(tokens, p.Token())
+	}
 	for p.Next() {
 		if p.Val() == "}" {
 			nesting--
 			if nesting == 0 {
+				if retainCurlies {
+					tokens = append(tokens, p.Token())
+				}
 				break
 			}
 		}
@@ -617,9 +650,10 @@ func (p *parser) snippetTokens() ([]Token, error) {
 // head of the server block with tokens, which are
 // grouped by segments.
 type ServerBlock struct {
-	HasBraces bool
-	Keys      []string
-	Segments  []Segment
+	HasBraces    bool
+	Keys         []string
+	Segments     []Segment
+	IsNamedRoute bool
 }
 
 // DispenseDirective returns a dispenser that contains
diff --git a/caddyconfig/httpcaddyfile/builtins.go b/caddyconfig/httpcaddyfile/builtins.go
index 45da4a8d0ae..d6cdf840768 100644
--- a/caddyconfig/httpcaddyfile/builtins.go
+++ b/caddyconfig/httpcaddyfile/builtins.go
@@ -48,6 +48,7 @@ func init() {
 	RegisterHandlerDirective("route", parseRoute)
 	RegisterHandlerDirective("handle", parseHandle)
 	RegisterDirective("handle_errors", parseHandleErrors)
+	RegisterHandlerDirective("invoke", parseInvoke)
 	RegisterDirective("log", parseLog)
 	RegisterHandlerDirective("skip_log", parseSkipLog)
 }
@@ -764,6 +765,27 @@ func parseHandleErrors(h Helper) ([]ConfigValue, error) {
 	}, nil
 }
 
+// parseInvoke parses the invoke directive.
+func parseInvoke(h Helper) (caddyhttp.MiddlewareHandler, error) {
+	h.Next() // consume directive
+	if !h.NextArg() {
+		return nil, h.ArgErr()
+	}
+	for h.Next() || h.NextBlock(0) {
+		return nil, h.ArgErr()
+	}
+
+	// remember that we're invoking this name
+	// to populate the server with these named routes
+	if h.State[namedRouteKey] == nil {
+		h.State[namedRouteKey] = map[string]struct{}{}
+	}
+	h.State[namedRouteKey].(map[string]struct{})[h.Val()] = struct{}{}
+
+	// return the handler
+	return &caddyhttp.Invoke{Name: h.Val()}, nil
+}
+
 // parseLog parses the log directive. Syntax:
 //
 //	log {
diff --git a/caddyconfig/httpcaddyfile/directives.go b/caddyconfig/httpcaddyfile/directives.go
index b8faa4a989e..12230132b7d 100644
--- a/caddyconfig/httpcaddyfile/directives.go
+++ b/caddyconfig/httpcaddyfile/directives.go
@@ -65,6 +65,7 @@ var directiveOrder = []string{
 	"templates",
 
 	// special routing & dispatching directives
+	"invoke",
 	"handle",
 	"handle_path",
 	"route",
diff --git a/caddyconfig/httpcaddyfile/httptype.go b/caddyconfig/httpcaddyfile/httptype.go
index 90c90ee6b22..c7aeb948397 100644
--- a/caddyconfig/httpcaddyfile/httptype.go
+++ b/caddyconfig/httpcaddyfile/httptype.go
@@ -52,8 +52,10 @@ type ServerType struct {
 }
 
 // Setup makes a config from the tokens.
-func (st ServerType) Setup(inputServerBlocks []caddyfile.ServerBlock,
-	options map[string]any) (*caddy.Config, []caddyconfig.Warning, error) {
+func (st ServerType) Setup(
+	inputServerBlocks []caddyfile.ServerBlock,
+	options map[string]any,
+) (*caddy.Config, []caddyconfig.Warning, error) {
 	var warnings []caddyconfig.Warning
 	gc := counter{new(int)}
 	state := make(map[string]any)
@@ -79,6 +81,11 @@ func (st ServerType) Setup(inputServerBlocks []caddyfile.ServerBlock,
 		return nil, warnings, err
 	}
 
+	originalServerBlocks, err = st.extractNamedRoutes(originalServerBlocks, options, &warnings)
+	if err != nil {
+		return nil, warnings, err
+	}
+
 	// replace shorthand placeholders (which are convenient
 	// when writing a Caddyfile) with their actual placeholder
 	// identifiers or variable names
@@ -172,6 +179,18 @@ func (st ServerType) Setup(inputServerBlocks []caddyfile.ServerBlock,
 				result.directive = dir
 				sb.pile[result.Class] = append(sb.pile[result.Class], result)
 			}
+
+			// specially handle named routes that were pulled out from
+			// the invoke directive, which could be nested anywhere within
+			// some subroutes in this directive; we add them to the pile
+			// for this server block
+			if state[namedRouteKey] != nil {
+				for name := range state[namedRouteKey].(map[string]struct{}) {
+					result := ConfigValue{Class: namedRouteKey, Value: name}
+					sb.pile[result.Class] = append(sb.pile[result.Class], result)
+				}
+				state[namedRouteKey] = nil
+			}
 		}
 	}
 
@@ -403,6 +422,77 @@ func (ServerType) evaluateGlobalOptionsBlock(serverBlocks []serverBlock, options
 	return serverBlocks[1:], nil
 }
 
+// extractNamedRoutes pulls out any named route server blocks
+// so they don't get parsed as sites, and stores them in options
+// for later.
+func (ServerType) extractNamedRoutes(
+	serverBlocks []serverBlock,
+	options map[string]any,
+	warnings *[]caddyconfig.Warning,
+) ([]serverBlock, error) {
+	namedRoutes := map[string]*caddyhttp.Route{}
+
+	gc := counter{new(int)}
+	state := make(map[string]any)
+
+	// copy the server blocks so we can
+	// splice out the named route ones
+	filtered := append([]serverBlock{}, serverBlocks...)
+	index := -1
+
+	for _, sb := range serverBlocks {
+		index++
+		if !sb.block.IsNamedRoute {
+			continue
+		}
+
+		// splice out this block, because we know it's not a real server
+		filtered = append(filtered[:index], filtered[index+1:]...)
+		index--
+
+		if len(sb.block.Segments) == 0 {
+			continue
+		}
+
+		// zip up all the segments since ParseSegmentAsSubroute
+		// was designed to take a directive+
+		wholeSegment := caddyfile.Segment{}
+		for _, segment := range sb.block.Segments {
+			wholeSegment = append(wholeSegment, segment...)
+		}
+
+		h := Helper{
+			Dispenser:    caddyfile.NewDispenser(wholeSegment),
+			options:      options,
+			warnings:     warnings,
+			matcherDefs:  nil,
+			parentBlock:  sb.block,
+			groupCounter: gc,
+			State:        state,
+		}
+
+		handler, err := ParseSegmentAsSubroute(h)
+		if err != nil {
+			return nil, err
+		}
+		subroute := handler.(*caddyhttp.Subroute)
+		route := caddyhttp.Route{}
+
+		if len(subroute.Routes) == 1 && len(subroute.Routes[0].MatcherSetsRaw) == 0 {
+			// if there's only one route with no matcher, then we can simplify
+			route.HandlersRaw = append(route.HandlersRaw, subroute.Routes[0].HandlersRaw[0])
+		} else {
+			// otherwise we need the whole subroute
+			route.HandlersRaw = []json.RawMessage{caddyconfig.JSONModuleObject(handler, "handler", subroute.CaddyModule().ID.Name(), h.warnings)}
+		}
+
+		namedRoutes[sb.block.Keys[0]] = &route
+	}
+	options["named_routes"] = namedRoutes
+
+	return filtered, nil
+}
+
 // serversFromPairings creates the servers for each pairing of addresses
 // to server blocks. Each pairing is essentially a server definition.
 func (st *ServerType) serversFromPairings(
@@ -542,6 +632,24 @@ func (st *ServerType) serversFromPairings(
 			}
 		}
 
+		// add named routes to the server if 'invoke' was used inside of it
+		configuredNamedRoutes := options["named_routes"].(map[string]*caddyhttp.Route)
+		for _, sblock := range p.serverBlocks {
+			if len(sblock.pile[namedRouteKey]) == 0 {
+				continue
+			}
+			for _, value := range sblock.pile[namedRouteKey] {
+				if srv.NamedRoutes == nil {
+					srv.NamedRoutes = map[string]*caddyhttp.Route{}
+				}
+				name := value.Value.(string)
+				if configuredNamedRoutes[name] == nil {
+					return nil, fmt.Errorf("cannot invoke named route '%s', which was not defined", name)
+				}
+				srv.NamedRoutes[name] = configuredNamedRoutes[name]
+			}
+		}
+
 		// create a subroute for each site in the server block
 		for _, sblock := range p.serverBlocks {
 			matcherSetsEnc, err := st.compileEncodedMatcherSets(sblock)
@@ -1469,6 +1577,7 @@ type sbAddrAssociation struct {
 }
 
 const matcherPrefix = "@"
+const namedRouteKey = "named_route"
 
 // Interface guard
 var _ caddyfile.ServerType = (*ServerType)(nil)
diff --git a/modules/caddyhttp/app.go b/modules/caddyhttp/app.go
index 53b57820dc9..0e02afdd3e6 100644
--- a/modules/caddyhttp/app.go
+++ b/modules/caddyhttp/app.go
@@ -293,11 +293,19 @@ func (app *App) Provision(ctx caddy.Context) error {
 		if srv.Errors != nil {
 			err := srv.Errors.Routes.Provision(ctx)
 			if err != nil {
-				return fmt.Errorf("server %s: setting up server error handling routes: %v", srvName, err)
+				return fmt.Errorf("server %s: setting up error handling routes: %v", srvName, err)
 			}
 			srv.errorHandlerChain = srv.Errors.Routes.Compile(errorEmptyHandler)
 		}
 
+		// provision the named routes (they get compiled at runtime)
+		for name, route := range srv.NamedRoutes {
+			err := route.Provision(ctx, srv.Metrics)
+			if err != nil {
+				return fmt.Errorf("server %s: setting up named route '%s' handlers: %v", name, srvName, err)
+			}
+		}
+
 		// prepare the TLS connection policies
 		err = srv.TLSConnPolicies.Provision(ctx)
 		if err != nil {
diff --git a/modules/caddyhttp/invoke.go b/modules/caddyhttp/invoke.go
new file mode 100644
index 00000000000..97fd1cc31e2
--- /dev/null
+++ b/modules/caddyhttp/invoke.go
@@ -0,0 +1,56 @@
+// Copyright 2015 Matthew Holt and The Caddy Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package caddyhttp
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/caddyserver/caddy/v2"
+)
+
+func init() {
+	caddy.RegisterModule(Invoke{})
+}
+
+// Invoke implements a handler that compiles and executes a
+// named route that was defined on the server.
+//
+// EXPERIMENTAL: Subject to change or removal.
+type Invoke struct {
+	// Name is the key of the named route to execute
+	Name string `json:"name,omitempty"`
+}
+
+// CaddyModule returns the Caddy module information.
+func (Invoke) CaddyModule() caddy.ModuleInfo {
+	return caddy.ModuleInfo{
+		ID:  "http.handlers.invoke",
+		New: func() caddy.Module { return new(Invoke) },
+	}
+}
+
+func (invoke *Invoke) ServeHTTP(w http.ResponseWriter, r *http.Request, next Handler) error {
+	server := r.Context().Value(ServerCtxKey).(*Server)
+	if route, ok := server.NamedRoutes[invoke.Name]; ok {
+		return route.Compile(next).ServeHTTP(w, r)
+	}
+	return fmt.Errorf("invoke: route '%s' not found", invoke.Name)
+}
+
+// Interface guards
+var (
+	_ MiddlewareHandler = (*Invoke)(nil)
+)
diff --git a/modules/caddyhttp/routes.go b/modules/caddyhttp/routes.go
index da25097bfa1..9be3d01a463 100644
--- a/modules/caddyhttp/routes.go
+++ b/modules/caddyhttp/routes.go
@@ -120,6 +120,59 @@ func (r Route) String() string {
 		r.Group, r.MatcherSetsRaw, handlersRaw, r.Terminal)
 }
 
+// Provision sets up both the matchers and handlers in the route.
+func (r *Route) Provision(ctx caddy.Context, metrics *Metrics) error {
+	err := r.ProvisionMatchers(ctx)
+	if err != nil {
+		return err
+	}
+	return r.ProvisionHandlers(ctx, metrics)
+}
+
+// ProvisionMatchers sets up all the matchers by loading the
+// matcher modules. Only call this method directly if you need
+// to set up matchers and handlers separately without having
+// to provision a second time; otherwise use Provision instead.
+func (r *Route) ProvisionMatchers(ctx caddy.Context) error {
+	// matchers
+	matchersIface, err := ctx.LoadModule(r, "MatcherSetsRaw")
+	if err != nil {
+		return fmt.Errorf("loading matcher modules: %v", err)
+	}
+	err = r.MatcherSets.FromInterface(matchersIface)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// ProvisionHandlers sets up all the handlers by loading the
+// handler modules. Only call this method directly if you need
+// to set up matchers and handlers separately without having
+// to provision a second time; otherwise use Provision instead.
+func (r *Route) ProvisionHandlers(ctx caddy.Context, metrics *Metrics) error {
+	handlersIface, err := ctx.LoadModule(r, "HandlersRaw")
+	if err != nil {
+		return fmt.Errorf("loading handler modules: %v", err)
+	}
+	for _, handler := range handlersIface.([]any) {
+		r.Handlers = append(r.Handlers, handler.(MiddlewareHandler))
+	}
+
+	// pre-compile the middleware handler chain
+	for _, midhandler := range r.Handlers {
+		r.middleware = append(r.middleware, wrapMiddleware(ctx, midhandler, metrics))
+	}
+	return nil
+}
+
+// Compile prepares a middleware chain from the route list.
+// This should only be done once during the request, just
+// before the middleware chain is executed.
+func (r Route) Compile(next Handler) Handler {
+	return wrapRoute(r)(next)
+}
+
 // RouteList is a list of server routes that can
 // create a middleware chain.
 type RouteList []Route
@@ -139,12 +192,7 @@ func (routes RouteList) Provision(ctx caddy.Context) error {
 // to provision a second time; otherwise use Provision instead.
 func (routes RouteList) ProvisionMatchers(ctx caddy.Context) error {
 	for i := range routes {
-		// matchers
-		matchersIface, err := ctx.LoadModule(&routes[i], "MatcherSetsRaw")
-		if err != nil {
-			return fmt.Errorf("route %d: loading matcher modules: %v", i, err)
-		}
-		err = routes[i].MatcherSets.FromInterface(matchersIface)
+		err := routes[i].ProvisionMatchers(ctx)
 		if err != nil {
 			return fmt.Errorf("route %d: %v", i, err)
 		}
@@ -158,25 +206,18 @@ func (routes RouteList) ProvisionMatchers(ctx caddy.Context) error {
 // to provision a second time; otherwise use Provision instead.
 func (routes RouteList) ProvisionHandlers(ctx caddy.Context, metrics *Metrics) error {
 	for i := range routes {
-		handlersIface, err := ctx.LoadModule(&routes[i], "HandlersRaw")
+		err := routes[i].ProvisionHandlers(ctx, metrics)
 		if err != nil {
-			return fmt.Errorf("route %d: loading handler modules: %v", i, err)
-		}
-		for _, handler := range handlersIface.([]any) {
-			routes[i].Handlers = append(routes[i].Handlers, handler.(MiddlewareHandler))
-		}
-
-		// pre-compile the middleware handler chain
-		for _, midhandler := range routes[i].Handlers {
-			routes[i].middleware = append(routes[i].middleware, wrapMiddleware(ctx, midhandler, metrics))
+			return fmt.Errorf("route %d: %v", i, err)
 		}
 	}
 	return nil
 }
 
 // Compile prepares a middleware chain from the route list.
-// This should only be done once: after all the routes have
-// been provisioned, and before serving requests.
+// This should only be done either once during provisioning
+// for top-level routes, or on each request just before the
+// middleware chain is executed for subroutes.
 func (routes RouteList) Compile(next Handler) Handler {
 	mid := make([]Middleware, 0, len(routes))
 	for _, route := range routes {
diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go
index 411ec72afde..d2de09b550f 100644
--- a/modules/caddyhttp/server.go
+++ b/modules/caddyhttp/server.go
@@ -102,6 +102,16 @@ type Server struct {
 	// The error routes work exactly like the normal routes.
 	Errors *HTTPErrorConfig `json:"errors,omitempty"`
 
+	// NamedRoutes describes a mapping of reusable routes that can be
+	// invoked by their name. This can be used to optimize memory usage
+	// when the same route is needed for many subroutes, by having
+	// the handlers and matchers be only provisioned once, but used from
+	// many places. These routes are not executed unless they are invoked
+	// from another route.
+	//
+	// EXPERIMENTAL: Subject to change or removal.
+	NamedRoutes map[string]*Route `json:"named_routes,omitempty"`
+
 	// How to handle TLS connections. At least one policy is
 	// required to enable HTTPS on this server if automatic
 	// HTTPS is disabled or does not apply.


Test patch:
diff --git a/caddytest/integration/caddyfile_adapt/invoke_named_routes.txt b/caddytest/integration/caddyfile_adapt/invoke_named_routes.txt
new file mode 100644
index 00000000000..83d9859ca93
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/invoke_named_routes.txt
@@ -0,0 +1,154 @@
+&(first) {
+	@first path /first
+	vars @first first 1
+	respond "first"
+}
+
+&(second) {
+	respond "second"
+}
+
+:8881 {
+	invoke first
+	route {
+		invoke second
+	}
+}
+
+:8882 {
+	handle {
+		invoke second
+	}
+}
+
+:8883 {
+	respond "no invoke"
+}
+----------
+{
+	"apps": {
+		"http": {
+			"servers": {
+				"srv0": {
+					"listen": [
+						":8881"
+					],
+					"routes": [
+						{
+							"handle": [
+								{
+									"handler": "invoke",
+									"name": "first"
+								},
+								{
+									"handler": "subroute",
+									"routes": [
+										{
+											"handle": [
+												{
+													"handler": "invoke",
+													"name": "second"
+												}
+											]
+										}
+									]
+								}
+							]
+						}
+					],
+					"named_routes": {
+						"first": {
+							"handle": [
+								{
+									"handler": "subroute",
+									"routes": [
+										{
+											"handle": [
+												{
+													"first": 1,
+													"handler": "vars"
+												}
+											],
+											"match": [
+												{
+													"path": [
+														"/first"
+													]
+												}
+											]
+										},
+										{
+											"handle": [
+												{
+													"body": "first",
+													"handler": "static_response"
+												}
+											]
+										}
+									]
+								}
+							]
+						},
+						"second": {
+							"handle": [
+								{
+									"body": "second",
+									"handler": "static_response"
+								}
+							]
+						}
+					}
+				},
+				"srv1": {
+					"listen": [
+						":8882"
+					],
+					"routes": [
+						{
+							"handle": [
+								{
+									"handler": "subroute",
+									"routes": [
+										{
+											"handle": [
+												{
+													"handler": "invoke",
+													"name": "second"
+												}
+											]
+										}
+									]
+								}
+							]
+						}
+					],
+					"named_routes": {
+						"second": {
+							"handle": [
+								{
+									"body": "second",
+									"handler": "static_response"
+								}
+							]
+						}
+					}
+				},
+				"srv2": {
+					"listen": [
+						":8883"
+					],
+					"routes": [
+						{
+							"handle": [
+								{
+									"body": "no invoke",
+									"handler": "static_response"
+								}
+							]
+						}
+					]
+				}
+			}
+		}
+	}
+}
\ No newline at end of file