Caddy’s matcher system is how it decides which directives to apply to an incoming request, and it’s way more powerful than just simple host or path matching.

Here’s a Caddyfile snippet that’s serving a static site, but only if the request comes from a specific IP address:

:80 {
    route {
        match remote_ip 192.168.1.100
        file_server
    }
    reverse_proxy localhost:8080
}

This is serving files from the current directory (file_server) if the request originates from 192.168.1.100. Any other request to port 80 will be forwarded to localhost:8080 (reverse_proxy).

The problem Caddy matchers solve is routing complexity. As your application grows, you’ll have different parts that need to be handled differently: some by static files, some by a backend API, some only for authenticated users, some only from specific geographical regions. Matchers let you define these conditions precisely.

Internally, Caddy processes requests by iterating through its configuration. For each incoming request, it checks all the defined route blocks. A route block contains one or more match directives followed by one or more directives to execute if all matches succeed. The first route block whose matches are all satisfied is the one whose directives will be executed.

The most common lever you have is route. This is the primary way to group matchers and their associated directives. Inside a route block, you can have multiple match directives. All of them must be true for the route to be taken.

Let’s look at some common matchers:

  • host: Matches based on the Host header of the request. This is your bread and butter for serving multiple domains from a single Caddy instance.

    example.com {
        reverse_proxy localhost:8000
    }
    api.example.com {
        reverse_proxy localhost:8001
    }
    

    Here, requests to example.com go to port 8000, and requests to api.example.com go to port 8001.

  • path: Matches based on the request URI path. You can use exact matches or glob patterns.

    example.com {
        route {
            match path /admin/*
            reverse_proxy localhost:9000
        }
        route {
            match path /api/*
            reverse_proxy localhost:9001
        }
        file_server
    }
    

    This configuration routes requests starting with /admin/ to port 9000, requests starting with /api/ to port 9001, and anything else to the file_server in the current directory.

  • header: Matches based on specific HTTP headers and their values. This is incredibly useful for things like versioning APIs or routing based on client capabilities.

    localhost {
        route {
            match header X-API-Version 2
            reverse_proxy localhost:8002
        }
        route {
            match header X-API-Version 1
            reverse_proxy localhost:8001
        }
        # Default if no version header is present
        reverse_proxy localhost:8000
    }
    

    This example routes requests to different backend versions based on the X-API-Version header.

  • method: Matches based on the HTTP request method (GET, POST, PUT, DELETE, etc.).

    localhost {
        route {
            match method POST
            reverse_proxy localhost:8081
        }
        file_server
    }
    

    In this case, only POST requests will be proxied to port 8081; all other methods will be served by file_server.

  • remote_ip: Matches based on the client’s IP address or a CIDR block.

    localhost {
        route {
            match remote_ip 10.0.0.0/8
            reverse_proxy internal_service:8000
        }
        file_server
    }
    

    This allows internal clients from the 10.0.0.0/8 range to access a specific internal service, while external requests are served static files.

  • expression: This is the most powerful matcher, allowing you to combine multiple conditions using logical operators (&& for AND, || for OR, ! for NOT). It’s a DSL that evaluates to true or false.

    example.com {
        route {
            match expression "{http.request.method} == GET && len({http.request.uri.path}) > 10"
            reverse_proxy localhost:8080
        }
        reverse_proxy localhost:8000
    }
    

    This route will only be taken if the request method is GET and the path length is greater than 10 characters. The http.request.* variables give you access to various parts of the request.

You can also negate matchers using not. For example, to match any request that is not from a specific IP:

localhost {
    route {
        not {
            match remote_ip 192.168.1.100
        }
        reverse_proxy localhost:8080
    }
    file_server
}

This serves localhost:8080 to everyone except 192.168.1.100, who will get the file_server.

The true power of matchers becomes apparent when you start nesting them or combining them with expression. For instance, you might want to serve a specific API version only to authenticated users (checked via a header) and only if they are using a GET request.

api.example.com {
    route {
        match header Authorization
        match header X-API-Version 2
        match method GET
        reverse_proxy localhost:8002
    }
    route {
        match header X-API-Version 2
        match method GET
        reverse_proxy localhost:8001 # Unauthenticated v2 GET
    }
    reverse_proxy localhost:8000 # Default
}

This shows how multiple route blocks are evaluated sequentially. Caddy will try the first route. If all its matchers succeed, it executes the directives and stops. If any matcher fails, it moves to the next route. This is crucial: the order of your route blocks matters.

What most people miss is that matchers can operate on any part of the request, not just the obvious ones. You can match on query parameters, request body content (though this is less common and can be inefficient), TLS client certificates, and even custom headers you inject yourself. The expression matcher, in particular, opens up a universe of possibilities by letting you programmatically define complex conditions based on a rich set of request variables.

The next thing to explore is how Caddy handles multiple route blocks and the implications of their order on request processing.

Want structured learning?

Take the full Caddy course →