nginx

Why does nginx skip location blocks?


I'm trying to achieve some redirection with location and return but nginx always seems to ignore/skip all of the location blocks and I don't understand why... I need to strip the /CustomContext part from the URL.

server {
    listen 8120 ssl; # legacy HTTPS port
    server_name server.domain.com;

    error_log /var/log/nginx/error.log debug;

    location / {
        return 444;
    }

    location ~ "^/CustomContext/(.*)$" {
        return 301 https://custom.domain.com/$1$is_args$args;
    }
    
    return 444;
}

I'd expect some "test location" entries after "rewrite phase: 1" in the log but there are none:

2025/06/24 11:45:18 [debug] 20#20: *1 http process request line
2025/06/24 11:45:18 [debug] 20#20: *1 http request line: "GET /CustomContext/services/version HTTP/1.1"
2025/06/24 11:45:18 [debug] 20#20: *1 http uri: "/CustomContext/services/version"
2025/06/24 11:45:18 [debug] 20#20: *1 http args: ""
2025/06/24 11:45:18 [debug] 20#20: *1 http exten: ""
2025/06/24 11:45:18 [debug] 20#20: *1 posix_memalign: 00007F47955376D0:4096 @16
2025/06/24 11:45:18 [debug] 20#20: *1 http process request header line
2025/06/24 11:45:18 [debug] 20#20: *1 http header: "Host: server.domain.com:8120"
2025/06/24 11:45:18 [debug] 20#20: *1 http header: "Connection: keep-alive"
2025/06/24 11:45:18 [debug] 20#20: *1 http header: "Pragma: no-cache"
2025/06/24 11:45:18 [debug] 20#20: *1 http header: "Cache-Control: no-cache"
2025/06/24 11:45:18 [debug] 20#20: *1 http header: "sec-ch-ua: "Microsoft Edge";v="137", "Chromium";v="137", "Not/A)Brand";v="24""
2025/06/24 11:45:18 [debug] 20#20: *1 http header: "sec-ch-ua-mobile: ?0"
2025/06/24 11:45:18 [debug] 20#20: *1 http header: "sec-ch-ua-platform: "Windows""
2025/06/24 11:45:18 [debug] 20#20: *1 http header: "Upgrade-Insecure-Requests: 1"
2025/06/24 11:45:18 [debug] 20#20: *1 http header: "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Edg/137.0.0.0"
2025/06/24 11:45:18 [debug] 20#20: *1 http header: "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
2025/06/24 11:45:18 [debug] 20#20: *1 http header: "Sec-Fetch-Site: none"
2025/06/24 11:45:18 [debug] 20#20: *1 http header: "Sec-Fetch-Mode: navigate"
2025/06/24 11:45:18 [debug] 20#20: *1 http header: "Sec-Fetch-User: ?1"
2025/06/24 11:45:18 [debug] 20#20: *1 http header: "Sec-Fetch-Dest: document"
2025/06/24 11:45:18 [debug] 20#20: *1 http header: "Accept-Encoding: gzip, deflate, br, zstd"
2025/06/24 11:45:18 [debug] 20#20: *1 http header: "Accept-Language: en-US,en;q=0.9,de;q=0.8,de-DE;q=0.7"
2025/06/24 11:45:18 [debug] 20#20: *1 http alloc large header buffer
2025/06/24 11:45:18 [debug] 20#20: *1 posix_memalign: 00007F4795546020:512 @16
2025/06/24 11:45:18 [debug] 20#20: *1 malloc: 00007F4795664AE0:8192
2025/06/24 11:45:18 [debug] 20#20: *1 http large header alloc: 00007F4795664AE0 8192
2025/06/24 11:45:18 [debug] 20#20: *1 http large header copy: 240
2025/06/24 11:45:18 [debug] 20#20: *1 SSL_read: 517
2025/06/24 11:45:18 [debug] 20#20: *1 SSL_read: -1
2025/06/24 11:45:18 [debug] 20#20: *1 SSL_get_error: 2
[...]
2025/06/24 11:45:18 [debug] 20#20: *1 http header done
2025/06/24 11:45:18 [debug] 20#20: *1 event timer del: 14: 176555899
2025/06/24 11:45:18 [debug] 20#20: *1 generic phase: 0
2025/06/24 11:45:18 [debug] 20#20: *1 rewrite phase: 1
2025/06/24 11:45:18 [debug] 20#20: *1 http finalize request: 444, "/CustomContext/services/version?" a:1, c:1
2025/06/24 11:45:18 [debug] 20#20: *1 http terminate request count:1
2025/06/24 11:45:18 [debug] 20#20: *1 http terminate cleanup count:1 blk:0
2025/06/24 11:45:18 [debug] 20#20: *1 http posted request: "/CustomContext/services/version?"
2025/06/24 11:45:18 [debug] 20#20: *1 http terminate handler count:1
2025/06/24 11:45:18 [debug] 20#20: *1 http request count:1 blk:0
2025/06/24 11:45:18 [debug] 20#20: *1 http close request
2025/06/24 11:45:18 [debug] 20#20: *1 http log handler
2025/06/24 11:45:18 [debug] 20#20: *1 free: 00007F47955352F0, unused: 112
2025/06/24 11:45:18 [debug] 20#20: *1 free: 00007F47955376D0, unused: 2672
2025/06/24 11:45:18 [debug] 20#20: *1 close http connection: 14
2025/06/24 11:45:18 [debug] 20#20: *1 SSL_shutdown: 1
2025/06/24 11:45:18 [debug] 20#20: *1 reusable connection: 0
2025/06/24 11:45:18 [debug] 20#20: *1 free: 00007F4795664

Switching to rewrite statement works:

server {
    listen 8120 ssl; # legacy HTTPS port
    server_name server.domain.com;

    rewrite ^/CustomContext/(.*)$ https://custom.domain.com/$1$is_args$args permanent;

    return 444;
}

Solution

  • I think this question could be answered better by explaining what actually happens under the hood.

    Each incoming HTTP request passes through a well-defined chain of request processing phases, described in the nginx development guide. The return directive is part of the ngx_http_rewrite_module, and when it's specified in the server context, it's executed during the NGX_HTTP_SERVER_REWRITE_PHASE. So, your request is terminated before it reaches the NGX_HTTP_FIND_CONFIG_PHASE, where the actual location to handle the request gets chosen based on the request URI. That's why you don't see any "test location" entries in your nginx error log. The rewrite directive also belongs to the ngx_http_rewrite_module, so your second configuration works correctly, finalizing every possible request during the NGX_HTTP_SERVER_REWRITE_PHASE.

    That doesn't mean you should never use the return directive in the server context when you have location blocks defined. As an example, it can be used inside an if block when you need to check access conditions that go beyond what can be handled with the allow/deny directives.

    In some cases — for example, when you need to serve ACME protocol requests over plain HTTP while redirecting everything else to HTTPS — multiple location blocks become unavoidable. You can't use something like:

    server {
        ...
        location /.well-known/acme-challenge/ {
            ...
        }
        return 301 https://$host$request_uri;
    }
    

    Instead, you need to use at least two separate locations:

    server {
        ...
        location /.well-known/acme-challenge/ {
            ...
        }
        location / {
            return 301 https://$host$request_uri;
        }
    }
    

    However, if the only logic in your nginx instance is what you've shown in your question, I recommend staying with the configuration similar to your second example:

    server {
        listen 8120 ssl; # legacy HTTPS port
        server_name server.domain.com;
    
        rewrite ^/CustomContext/(.*)$ https://custom.domain.com/$1 permanent;
        return 444;
    }
    

    This way, all request processing is completed during the NGX_HTTP_SERVER_REWRITE_PHASE, which should be slightly more performant compared to the configuration with multiple location blocks. Note that you don't need to manually append the query string in the rewrite directive — it will be preserved automatically, unless your replacement string ends with a question mark, as explained in the documentation.

    It's worth noting that any return or rewrite directive placed inside a location block is executed during the NGX_HTTP_REWRITE_PHASE, which is also fairly early in the request processing chain. This is another common source of confusion and errors, since many novice nginx users don't realize that regardless of where in the location block the directive is placed, it will be executed before other directives that do not belong to the ngx_http_rewrite_module — such as those that are executed during the NGX_HTTP_ACCESS_PHASE (see this Q/A for an example).


    Update based on additional information provided by the OP in comments.

    I thought the general rule was to avoid rewrite statements wherever possible.

    In general, this rule makes sense. Each request passes through the NGX_HTTP_SERVER_REWRITE_PHASE, during which all rewrite statements declared in the server context are evaluated — or more precisely, until the first statement using one of the break, last, permanent, or redirect flags is matched. In contrast, a well-structured set of location blocks can reduce the number of regex evaluations — especially when using prefix-based location blocks with the ^~ modifier.

    As an example, instead of a rewrite rule like this:

    rewrite ^/exact_uri$ ...;
    

    it's always better to use an "exact match" location block:

    location = /exact_uri {
        return ...;
    }
    

    Nginx implements a rather sophisticated and optimized algorithm to select the most appropriate location block for a given request URI. You can read more about the algorithm itself in this Q/A on ServerFault. Internally, this algorithm constructs a balanced tree — more precisely, a variant known as a red-black tree — to store the ordered set of all prefix-based location blocks defined in the configuration.

    I went with different location blocks for each context path and can see from the debug messages that it stops processing as soon as it finds a match.

    In your original question, replacing two rewrite + return directives with two location blocks didn't bring any practical benefit, since rewrite ... permanent statements also stop processing after the first match. In fact, it could even lead to slightly worse performance, as that approach introduces two additional processing phases for every request — NGX_HTTP_FIND_CONFIG_PHASE and NGX_HTTP_REWRITE_PHASE. Similarly, using 10-15 regex location blocks instead of the same number of rewrite statements won't make any difference. However, based on your follow-up:

    We actually have 10–15 different context paths which we want to redirect to different domain names from now on.

    and assuming that all context paths are defined using URI prefixes, the most efficient configuration in your case might look like this:

    location /FirstContext/ {
        rewrite ^/FirstContext/(.*)$ https://first.domain.com/$1 permanent;
    }
    location /SecondContext/ {
        rewrite ^/SecondContext/(.*)$ https://second.domain.com/$1 permanent;
    }
    location /ThirdContext/ {
        rewrite ^/ThirdContext/(.*)$ https://third.domain.com/$1 permanent;
    }
    ...
    location / {
        return 444;
    }
    

    With this setup, you limit regex processing to at most one libpcre library call per request, instead of an unpredictable number of calls (up to 10–15), which could be quite expensive in terms of performance.

    The second update to the answer.

    Went further and ran some tests — the results can be seen here. As you can see, when using twelve prefix location blocks (plus a thirteenth fallback one), nginx builds a balanced tree during configuration parsing, so the number of tested locations never exceeds five. Moreover, testing a prefix-based location block against the request URI is incomparably faster than invoking the libpcre library to match a string against the regular expression pattern.