namalzi

← All posts

Digging into nginx's Source 9: The 11 Phases and Finding the location — In What Order a Request Is Handled

nginx · Published

Once its headers are read, a request walks through 11 phases inside nginx. We check in the source how the handlers that modules register are flattened at config time into a single ph[] array, and how ngx_http_core_run_phases() drives that array through checker functions. We also see how location blocks become a prefix tree and are chosen in the order =, ^~, regex and 1.31.x's predicate locations, and which slot of the array rewrite last/break, internal redirects and named locations go back to — with debug logs and gdb.

What this post covers

This is the ninth post in the "Digging into nginx's Source" series. In Part 8 we read the request line and headers off a connection, built an ngx_http_request_t, and picked a server {} block from the Host header. The last function in Part 8, ngx_http_process_request(), ends by calling ngx_http_handler(r). This post starts right there.

There are four questions to answer.

Item Details
Target nginx
Version nginx 1.31.9 (master, development version)
Commit 9a7439fd79 (2026-10-23)
Files read most src/http/ngx_http_core_module.c/.h, src/http/ngx_http.c, src/http/modules/ngx_http_rewrite_module.c, src/http/ngx_http_script.c, src/http/ngx_http_variables.c

Every line number in this post refers to that commit. Part 1 explains how to get the source at the same commit and build the Docker image used for the examples.

On the map from Part 2, this post zooms into the Phases · location box inside "C. One HTTP request". ngx_http_core_module.c alone is over 5,500 lines, so this post keeps only the main thread that a first-time reader can follow. What was left out is named, with its location, under "What this post leaves out" at the end.

The series map. Of A Preparation (Startup · cycle Part 4, Modules · directives Part 5, Memory pools · data structures Part 3), B Processes and events (Master · workers Part 6, Event loop Part 7) and C One HTTP request (Reading the request Part 8, Phases · location Part 9, Filters · static files Part 10, Upstream · proxy Part 11, Load balancing · stream Part 12), only the Phases · location Part 9 box is colored green. Where this post sits on the Part 2 map: after the request has been read (Part 8) and before the response is produced and sent (Parts 10 and 11).

Words to know first

A phase is one of the eleven slots that nginx divides request processing into — "the slot that rewrites the address", "the slot that picks the location", "the slot that checks access", "the slot that produces the response", and so on. The nginx core fixes the order; modules register functions saying "call me in this slot".

A phase handler is a function a module registered for one phase. They all have the same shape, ngx_int_t handler(ngx_http_request_t *r), and the return value says "handled" (NGX_OK), "not my job" (NGX_DECLINED), "not finished yet, call me later" (NGX_AGAIN), or an HTTP status code (403 and so on).

A checker is a core function, one per phase, that calls the handler on the core's behalf and interprets its return value. The same NGX_OK can mean "skip to the next phase" in one phase and "finish the response" in another. It is function pointers two layers deep (if C function pointers are new to you, httpd Part 2 is a good warm-up).

loc_conf is an array of pointers holding "every module's configuration for one location". The slot number is the module's number (ctx_index). The request has its own r->loc_conf too, and picking a location means swapping this one pointer for the chosen location's array. A module gets its own settings with ngx_http_get_module_loc_conf(r, module), which is (r)->loc_conf[module.ctx_index] (ngx_http_config.h line 58). How the array is built is covered in Part 5.

A content handler is the function that produces the response body. Directives that take over a whole location, like proxy_pass, fastcgi_pass and stub_status, don't register in a phase; they plant a handler in that location's configuration instead.

An internal redirect means changing the URI in the middle of processing and processing it again. The browser never knows. A named location is a location like location @name {} that cannot be found by URI and is used only as the target of an internal redirect.

A variable is a name like $uri or $arg_beta whose value differs per request. When the config is read, the name is turned into a number (an index); the value is computed the first time a request asks for it (section 5).

1. The 11 phases — the core sets the order, modules fill them in

The phases are defined by a single enum.

typedef enum {
    NGX_HTTP_POST_READ_PHASE = 0,

    NGX_HTTP_SERVER_REWRITE_PHASE,

    NGX_HTTP_FIND_CONFIG_PHASE,
    NGX_HTTP_REWRITE_PHASE,
    NGX_HTTP_POST_REWRITE_PHASE,

    NGX_HTTP_PREACCESS_PHASE,

    NGX_HTTP_ACCESS_PHASE,
    NGX_HTTP_POST_ACCESS_PHASE,

    NGX_HTTP_PRECONTENT_PHASE,

    NGX_HTTP_CONTENT_PHASE,

    NGX_HTTP_LOG_PHASE
} ngx_http_phases;

src/http/ngx_http_core_module.h lines 110–129

That is eleven, numbered 0 to 10. The blank lines are a hint: FIND_CONFIG, REWRITE and POST_REWRITE form one group, and ACCESS and POST_ACCESS another. To find out who goes into each phase, search the source for places that ngx_array_push onto cmcf->phases[...].handlers.

The 11 phases. 0 POST_READ (realip), 1 SERVER_REWRITE (rewrite · return · set at server level), 2 FIND_CONFIG (core only, find the location), 3 REWRITE (rewrite · return · set inside the location), 4 POST_REWRITE (core only, did the URI change?), 5 PREACCESS (limit_req · limit_conn · realip), 6 ACCESS (allow·deny, auth_basic, auth_request), 7 POST_ACCESS (core only, act on a refusal), 8 PRECONTENT (try_files · mirror), 9 CONTENT (a location handler such as proxy_pass, or index · autoindex · static) follow in order. If rewrite last changed the URI, POST_REWRITE goes back to FIND_CONFIG. 10 LOG (access_log) is called separately by ngx_http_log_request after the response. Blue boxes are phases where modules register handlers; gray boxes are phases where only the core works. The green CONTENT box is where the response is made, and LOG is gray because, although access_log registers there, it is called separately, outside the ph[] array below. The module names are for this post's build (the configure arguments from Part 1).

phase What you know from nginx.conf Where it is registered (base commit)
0 POST_READ real_ip_header ngx_http_realip_module.c line 526
1 SERVER_REWRITE rewrite, return, set, if directly under server {} ngx_http_rewrite_module.c line 279
2 FIND_CONFIG picking the location (core)
3 REWRITE rewrite, return, set, if inside location {} ngx_http_rewrite_module.c line 286
4 POST_REWRITE finding the location again after rewrite ... last (core)
5 PREACCESS limit_req, limit_conn ngx_http_limit_req_module.c line 1094, etc.
6 ACCESS allow/deny, auth_basic, auth_request ngx_http_access_module.c line 455, etc.
7 POST_ACCESS settling the result of satisfy any (core)
8 PRECONTENT try_files, mirror ngx_http_try_files_module.c line 411, etc.
9 CONTENT proxy_pass, static files, index, autoindex ngx_http_static_module.c line 289, etc.
10 LOG access_log ngx_http_log_module.c line 2016

The code a module uses to plug in is tiny. The rewrite module's postconfiguration callback puts the same function into two phases; here is the first half.

    h = ngx_array_push(&cmcf->phases[NGX_HTTP_SERVER_REWRITE_PHASE].handlers);
    if (h == NULL) {
        return NGX_ERROR;
    }

    *h = ngx_http_rewrite_handler;

src/http/modules/ngx_http_rewrite_module.c lines 279–284

cmcf is the core module's main configuration, and its phases[NGX_HTTP_LOG_PHASE + 1] (line 178) holds one ngx_http_phase_t per phase, whose single field handlers is the ngx_array_t that collects the handlers (the dynamic array from Part 3; lines 150–152). That is the whole registration. No macros, no ordering argument.

Comparing with httpd from httpd Part 10 makes the difference clear. httpd has a named hook per step, each hook has a rule such as "the first one to answer wins" (RUN_FIRST) or "run them all" (RUN_ALL) baked in by macros, and modules choose their order with values like APR_HOOK_FIRST. In nginx every handler has the same shape, the per-phase rules live in the checker, and modules cannot choose their order. The order within a phase comes from the module list that configure generates (objs/ngx_modules.c, Part 5). And while httpd's configuration-choosing map_to_storage was a hook, nginx's FIND_CONFIG is not open to modules.

2. Once, at config time — flattening phases[] into a single ph[] array

Instead of looking through eleven separate arrays for every request, nginx flattens them into one array after reading the http block. One slot is an ngx_http_phase_handler_t.

typedef struct ngx_http_phase_handler_s  ngx_http_phase_handler_t;

typedef ngx_int_t (*ngx_http_phase_handler_pt)(ngx_http_request_t *r,
    ngx_http_phase_handler_t *ph);

struct ngx_http_phase_handler_s {
    ngx_http_phase_handler_pt  checker;
    ngx_http_handler_pt        handler;
    ngx_uint_t                 next;
};


typedef struct {
    ngx_http_phase_handler_t  *handlers;
    ngx_uint_t                 server_rewrite_index;
    ngx_uint_t                 location_rewrite_index;
} ngx_http_phase_engine_t;

src/http/ngx_http_core_module.h lines 131–147

Field Meaning
checker the core function that handles this slot. All slots of one phase share a checker
handler the handler a module registered. NULL for core-only slots
next the slot to jump to when the answer is "skip this phase". Usually the first slot of the next phase

ngx_http_init_phase_handlers(), which builds the array, is called after ngx_http_block() has run every module's postconfiguration (ngx_http.c lines 303–331). It first adds up the core slots and the module handlers and allocates (lines 470–479), and two things stand out. The counting loop only runs up to i < NGX_HTTP_LOG_PHASE, so the LOG phase is not in this array (section 3); and the extra sizeof(void *) that ngx_pcalloc() zero-fills leaves a final slot with checker == NULL — the end marker. Then it walks the phases and fills the slots.

        case NGX_HTTP_FIND_CONFIG_PHASE:
            find_config_index = n;

            ph->checker = ngx_http_core_find_config_phase;
            n++;
            ph++;

            continue;

        /* ... */

        case NGX_HTTP_POST_REWRITE_PHASE:
            if (use_rewrite) {
                ph->checker = ngx_http_core_post_rewrite_phase;
                ph->next = find_config_index;
                n++;
                ph++;
            }

            continue;

        case NGX_HTTP_ACCESS_PHASE:
            checker = ngx_http_core_access_phase;
            n++;
            break;

        /* ... */

        default:
            checker = ngx_http_core_generic_phase;
        }

        n += cmcf->phases[i].handlers.nelts;

        for (j = cmcf->phases[i].handlers.nelts - 1; j >= 0; j--) {
            ph->checker = checker;
            ph->handler = h[j];
            ph->next = n;
            ph++;
        }

src/http/ngx_http.c lines 500–556

Example 1: printing the ph[] array with gdb

I printed the array of a running nginx. This example was run with nginx 1.31.9 built unchanged from the base commit (9a7439fd79) in a Docker container (Debian bookworm, --with-debug). The example folder is ex/u09-phase-engine; this is the http {} part of its config (the rest, such as error_log ... debug and events {}, is in the folder's nginx.conf).

http {
    access_log /tmp/u09/access.log;
    root /tmp/u09/www;

    server {
        listen 8080;

        location / {
        }

        location /admin/ {
            deny all;
        }
    }
}

To make gdb's job easy, nginx runs as a single process with -g 'daemon off; master_process off;', a breakpoint goes on ngx_http_core_run_phases, and curl requests /index.html. When it stops, a gdb script (phases.gdb) takes the core module's main configuration out of r->main_conf and prints phase_engine.handlers slot by slot with info symbol until it reaches the slot whose checker is NULL. run.sh tidies that output (dropping in section .text and the binary's path) and puts each slot's checker <- handler on one line.

=== 1. ph[] array (gdb, master_process off) ===
server_rewrite_index=1 location_rewrite_index=3
ph[ 0] next= 1  ngx_http_core_generic_phase  <- ngx_http_realip_handler
ph[ 1] next= 2  ngx_http_core_rewrite_phase  <- ngx_http_rewrite_handler
ph[ 2] next= 0  ngx_http_core_find_config_phase
ph[ 3] next= 4  ngx_http_core_rewrite_phase  <- ngx_http_rewrite_handler
ph[ 4] next= 2  ngx_http_core_post_rewrite_phase
ph[ 5] next= 8  ngx_http_core_generic_phase  <- ngx_http_realip_handler
ph[ 6] next= 8  ngx_http_core_generic_phase  <- ngx_http_limit_req_handler
ph[ 7] next= 8  ngx_http_core_generic_phase  <- ngx_http_limit_conn_handler
ph[ 8] next=12  ngx_http_core_access_phase  <- ngx_http_access_handler
ph[ 9] next=12  ngx_http_core_access_phase  <- ngx_http_auth_basic_handler
ph[10] next=12  ngx_http_core_access_phase  <- ngx_http_auth_request_handler
ph[11] next=12  ngx_http_core_post_access_phase
ph[12] next=14  ngx_http_core_generic_phase  <- ngx_http_try_files_handler
ph[13] next=14  ngx_http_core_generic_phase  <- ngx_http_mirror_handler
ph[14] next=18  ngx_http_core_content_phase  <- ngx_http_index_handler
ph[15] next=18  ngx_http_core_content_phase  <- ngx_http_autoindex_handler
ph[16] next=18  ngx_http_core_content_phase  <- ngx_http_gzip_static_handler
ph[17] next=18  ngx_http_core_content_phase  <- ngx_http_static_handler
ph[18] checker=NULL (end)

The flattened ph array. ph[0] generic_phase with realip, ph[1] rewrite_phase with rewrite_handler (server_rewrite_index), ph[2] find_config_phase (no handler), ph[3] rewrite_phase with rewrite_handler (location_rewrite_index), ph[4] post_rewrite_phase (next=2), ph[5]–ph[7] generic_phase (realip, limit_req, limit_conn), ph[8]–ph[10] access_phase (access, auth_basic, auth_request, next=12), ph[11] post_access_phase, ph[12]–ph[13] generic_phase (try_files, mirror), ph[14]–ph[17] content_phase (index, autoindex, gzip_static, static), ending with ph[18] checker=NULL. Dotted lines go from post_rewrite to ph[2] if the URI changed, and from the access slots to ph[12] on OK with satisfy any. Example 1's output as a picture. The slot numbers come from this build's set of modules and change if modules are added or removed.

3. Every time, per request — ngx_http_core_run_phases() and the checkers

The starting point is ngx_http_handler(), called at the very end of Part 8's ngx_http_process_request() (ngx_http_request.c line 2217). After some housekeeping such as keep-alive, it sets r->phase_handler, hooks up r->write_event_handler = ngx_http_core_run_phases, and calls that function straight away (lines 854–890). r->phase_handler is an integer that remembers "which slot of ph[] are we in"; a new request starts at 0 and a request that came back through an internal redirect (r->internal) starts at server_rewrite_index (section 6). write_event_handler is for waking up after a pause. That run loop is the shortest and most important function in this post.

void
ngx_http_core_run_phases(ngx_http_request_t *r)
{
    ngx_int_t                   rc;
    ngx_http_phase_handler_t   *ph;
    ngx_http_core_main_conf_t  *cmcf;

    cmcf = ngx_http_get_module_main_conf(r, ngx_http_core_module);

    ph = cmcf->phase_engine.handlers;

    while (ph[r->phase_handler].checker) {

        rc = ph[r->phase_handler].checker(r, &ph[r->phase_handler]);

        if (rc == NGX_OK) {
            return;
        }
    }
}

src/http/ngx_http_core_module.c lines 893–912

The loop never advances phase_handler itself. The checker moves r->phase_handler, and the loop only looks at what the checker returns. Here the return values mean something different from a handler's: a checker's NGX_AGAIN means "keep going" and NGX_OK means "that's all for now". The loop exits with NGX_OK either when the response is finished or when a handler has to wait for something (the request body, a subrequest, a limit_req delay …). In the second case r->phase_handler is left as it is, so when write_event_handler brings us back later, processing resumes at the slot where it stopped. That is how nginx steps through phases on top of the event loop from Part 7 without blocking.

The exchange in the run loop. When the event loop has read the headers, ngx_http_handler sets phase_handler to 0 and write_event_handler to run_phases, and calls ngx_http_core_run_phases right away. run_phases calls ph[phase_handler].checker, which calls the module handler. If the handler returns NGX_DECLINED, the checker increments phase_handler and returns NGX_AGAIN, so the loop moves to the next slot. If the next handler returns NGX_AGAIN, the checker returns NGX_OK and the loop returns to the event loop. When r->write_event_handler calls it again later, phase_handler is unchanged, so it resumes at the same slot. Drawn for the generic and access checkers. Each checker reads the same return value differently (table below).

Seven checkers — same return values, different readings

Here is the basic one, ngx_http_core_generic_phase().

    rc = ph->handler(r);

    if (rc == NGX_OK) {
        r->phase_handler = ph->next;
        return NGX_AGAIN;
    }

    if (rc == NGX_DECLINED) {
        r->phase_handler++;
        return NGX_AGAIN;
    }

    if (rc == NGX_AGAIN || rc == NGX_DONE) {
        return NGX_OK;
    }

    /* rc == NGX_ERROR || rc == NGX_HTTP_...  */

    ngx_http_finalize_request(r, rc);

    return NGX_OK;
}

src/http/ngx_http_core_module.c lines 928–949

If the handler returns NGX_OK, the rest of this phase's handlers are skipped and we go to next; on NGX_DECLINED we go to the next handler of the same phase. NGX_AGAIN/NGX_DONE stop the loop, and anything else finishes the request with that value. The other checkers vary this pattern a little.

checker used by differs from generic location
ngx_http_core_generic_phase POST_READ, PREACCESS, PRECONTENT (baseline) lines 915–949
ngx_http_core_rewrite_phase SERVER_REWRITE, REWRITE only DECLINED moves on. NGX_OK and NGX_AGAIN are also values that finish the request lines 952–976
ngx_http_core_find_config_phase FIND_CONFIG no handler. Finds the location and swaps r->loc_conf lines 979–1071
ngx_http_core_post_rewrite_phase POST_REWRITE goes back to FIND_CONFIG if r->uri_changed lines 1074–1115
ngx_http_core_access_phase ACCESS skips everything for subrequests. Reads OK and 403 differently depending on satisfy lines 1118–1192
ngx_http_core_post_access_phase POST_ACCESS finishes with the noted r->access_code, if any lines 1195–1226
ngx_http_core_content_phase CONTENT only the content handler, if there is one. 403 or 404 if nobody takes the request lines 1301–1351

The rewrite checker reads NGX_OK as "done" because of how the rewrite module works. A return with a body, like return 200 "...";, sends the response on the spot with ngx_http_send_response() and returns its result (usually NGX_OK); a return 404; without a body returns the status code (ngx_http_script.c lines 1577–1585). If nothing happened, it is NGX_DECLINED (section 5). Either way there is no "handled, move on to the next phase" case, and NGX_OK means "the response has already been sent, wrap it up".

The find_config checker calls section 4's ngx_http_core_find_location(), checks the chosen location's internal flag (404 for an outside request) and writes using configuration "..." to the debug log (lines 998–1008). The ngx_http_update_location_config() it calls next copies the chosen location's settings (error log, sendfile, keep-alive limits …) onto the request and connection, and its last lines set the content handler.

    if (clcf->handler) {
        r->content_handler = clcf->handler;
    }

src/http/ngx_http_core_module.c lines 1431–1433

The clcf->handler = ngx_http_proxy_handler; planted when the proxy_pass directive was read (ngx_http_proxy_module.c line 4397) becomes the request's content handler here. If it is set, the content checker calls only that at its very start (lines 1309–1313) and finishes, so index and static are never called in that location. The story continues in Part 11.

satisfy any — why ACCESS's next skips POST_ACCESS

With satisfy all (the default) a single refusal from any access module ends it; with satisfy any a single approval lets the request through. The access checker reads return values differently depending on clcf->satisfy (lines 1146–1181). Under satisfy any, NGX_OK means "no need to look at the remaining checks", so it jumps to ph->next. That is why section 2 pointed ACCESS's next at the slot after POST_ACCESS (ph[12]). A refusal is only noted in r->access_code and the next handler gets its chance; if nobody approves by the end, the POST_ACCESS slot finishes the request with the noted code. Under satisfy all, by contrast, NGX_OK means phase_handler++ (on to the next check), and a refusal is finished right away by the common code (lines 1185–1191).

Example 1 continued: slot order in the debug log

With the same config, nginx was started normally and the phase lines were picked out of the debug log for two requests. The date and pid at the start of each line were removed, and connection numbers such as *2 differ from run to run.

=== 2. debug log: GET /index.html ===
status=200
*2 generic phase: 0
*2 rewrite phase: 1
*2 test location: "/"
*2 test location: "admin/"
*2 using configuration "/"
*2 rewrite phase: 3
*2 post rewrite phase: 4
*2 generic phase: 5
...
*2 post access phase: 11
*2 generic phase: 12
*2 generic phase: 13
*2 content phase: 14
*2 content phase: 15
*2 content phase: 16
*2 content phase: 17
*2 http finalize request: 0, "/index.html?" a:1, c:1
*2 http log handler
=== 3. debug log: GET /admin/ (deny all) ===
status=403
...
*3 using configuration "/admin/"
*3 rewrite phase: 3
*3 post rewrite phase: 4
*3 generic phase: 5
*3 generic phase: 6
*3 generic phase: 7
*3 access phase: 8
*3 access forbidden by rule, client: 127.0.0.1, server: , request: "GET /admin/ HTTP/1.1", host: "127.0.0.1:8080"
*3 http finalize request: 403, "/admin/?" a:1, c:1
*3 http finalize request: 0, "/admin/?" a:1, c:1

(... marks where lines of the same shape, with the slot number going up by one each time, or lines identical to the previous request, were cut.)

Easy to get wrong: 404 when nobody answers, but 403 for a trailing "/"

If every slot up to the last CONTENT slot returns NGX_DECLINED, the content checker finishes the request itself. If the URI ends in /, it logs "directory index of ... is forbidden" and returns 403; otherwise it logs "no handler found" and returns 404 (lines 1334–1351). That is why a directory without index.html gives 403 rather than 404. The ordinary 404 for a missing file is decided not here but by the static module when it fails to open the file.

4. Finding the location — a tree at config time, a walk down it per request

Now we go inside the FIND_CONFIG slot. "Who wins when =, ^~, ~ and plain prefixes overlap" lives here.

4-1. One modifier character becomes a flag

When a location block is met, ngx_http_core_location() creates a new loc_conf array using every module's create_loc_conf (lines 3174–3205) and reads the modifier to set flags in the core module's configuration (lines 3209–3301).

Config Flag Meaning
location = /img exact_match = 1 only when the URI is exactly equal
location ^~ /img/static/ noregex = 1 a prefix; if it is the longest prefix, regexes are not looked at
location ~ \.png$, ~* regex != NULL a regex (compiled when the config is read). ~* ignores case
location $is_beta predicate = variable index + 1 a predicate location, new in 1.31.x
location @fallback named = 1 a location found only by name
location /img/ (none) a plain prefix

The predicate location, whose name starts with $, is a new feature in this version. It came in with commit 5f54125dd ("Predicate locations", authored 2026-03-29, committed 2026-08-26), and the word predicate does not appear at all in ngx_http_core_module.h of the 1.30.6 stable release. The commit message explains: "a name starting with $ is treated as a string evaluated at request time, and it matches if the value is neither empty nor "0". It is tried after prefix and regex locations." In the source, though, everything after the $ is taken as a single variable name: ngx_http_get_variable_index() turns it into an index, stored in predicate plus one (lines 3279–3291). We check that against the source and an example below.

Then ngx_http_add_location() puts it into the parent's locations queue, splitting into two kinds (lines 909–921). Plain prefixes (including ^~) go to inclusive ("takes every URI starting with that name"); everything else (=, regexes, predicates, named and nameless ones) goes to exact.

4-2. Sort, split off, build a tree

Once the http block has been read, ngx_http_block() calls ngx_http_init_locations() and ngx_http_init_static_location_trees() for each server (ngx_http.c lines 280–291).

  1. Sort: the comparison function ngx_http_cmp_locations() (lines 970–1055) lines the queue up as prefixes and exact matches (by name) → regexes → predicates → named (by name) → nameless (if blocks and the like). For regexes and predicates it returns 0 with a "do not sort" comment, and since ngx_queue_sort() is a stable merge sort, the order written in the config is kept.
  2. Split off: ngx_http_init_locations() cuts the named, predicate and regex groups off the back of the sorted queue and turns each into an array of pointers — cscf->named_locations, pclcf->predicate_locations, pclcf->regex_locations (lines 756–826). Nested locations get the same treatment recursively (line 709).
  3. Build the tree: among the remaining prefixes and exact matches, an = and a prefix with the same name are merged into one entry (two of the same kind is a "duplicate location" error), locations that start with a given prefix are moved underneath it, and a balanced binary tree is built with the middle of the sorted list as its root (lines 1058–1233).
struct ngx_http_location_tree_node_s {
    ngx_http_location_tree_node_t   *left;
    ngx_http_location_tree_node_t   *right;
    ngx_http_location_tree_node_t   *tree;

    ngx_http_core_loc_conf_t        *exact;
    ngx_http_core_loc_conf_t        *inclusive;

    u_short                          len;
    u_char                           auto_redirect;
    u_char                           name[1];
};

src/http/ngx_http_core_module.h lines 478–489

left and right make an ordinary binary search tree; the unusual part is the third child, tree. Locations starting with /img/ form their own tree under the /img/ node's tree, and those nodes store in name only what is left after removing the part the parent already matched (prefix + len, line 1227). A node can hold both exact and inclusive, so location = /x and location /x share one node. name[1] is an old C idiom for allocating the name at the end of the struct (lines 1177–1178).

The location tree of Example 2. The root node has name / and inclusive location /. Its tree child (first 1 char removed) is the node img/ with inclusive location /img/, whose left child is the node img with exact location = /img. The img/ node's tree child (first 5 chars removed) is the node static/ with inclusive location ^~ /img/static/ and noregex=1, whose left child is icons/ for location /img/icons/. Beside the tree are the regex_locations array (a regex ending in png) and the predicate_locations array ($is_beta) in config order; after the tree come the regexes, then the predicates. The tree built from Example 2's config. Yellow boxes are nodes built at config time; name is what is left after removing the prefix the parent matched.

4-3. Per request: walking down the tree

The loop of ngx_http_core_find_static_location() (the debug log line is elided):

    for ( ;; ) {

        if (node == NULL) {
            return rv;
        }

        /* ... "test location: \"%*s\"" debug log ... */

        n = (len <= (size_t) node->len) ? len : node->len;

        rc = ngx_filename_cmp(uri, node->name, n);

        if (rc != 0) {
            node = (rc < 0) ? node->left : node->right;

            continue;
        }

        if (len > (size_t) node->len) {

            if (node->inclusive) {

                r->loc_conf = node->inclusive->loc_conf;
                rv = NGX_AGAIN;

                node = node->tree;
                uri += n;
                len -= n;

                continue;
            }

            /* exact only */

            node = node->right;

            continue;
        }

        if (len == (size_t) node->len) {

            if (node->exact) {
                r->loc_conf = node->exact->loc_conf;
                return NGX_OK;

            } else {
                r->loc_conf = node->inclusive->loc_conf;
                return NGX_AGAIN;
            }
        }

        /* ... len < node->len: check auto_redirect, then go left ... */
    }

src/http/ngx_http_core_module.c lines 1557–1620

The comparison function ngx_filename_cmp() compares byte by byte, is case-sensitive (only builds for case-insensitive file systems apply tolower), and treats / as the smallest character (ngx_string.c lines 926–963). Because sorting and searching use the same function, the tree stays consistent.

4-4. Then the regexes, then the predicates

ngx_http_core_find_location(), which wraps the tree search, decides the rest of the rules.

    rc = ngx_http_core_find_static_location(r, pclcf->static_locations);

    if (rc == NGX_AGAIN) {

        clcf = ngx_http_get_module_loc_conf(r, ngx_http_core_module);

        noregex = clcf->noregex;

        /* look up nested locations */

        rc = ngx_http_core_find_location(r);
    }

    if (rc == NGX_OK || rc == NGX_DONE || rc == NGX_ERROR) {
        return rc;
    }

    /* rc == NGX_DECLINED or rc == NGX_AGAIN in nested location */

#if (NGX_PCRE)

    if (noregex == 0 && pclcf->regex_locations) {

        /* ... ngx_http_regex_exec() in config order; on the first match swap r->loc_conf, NGX_OK ... */
    }
#endif

    if (noregex == 0 && pclcf->predicate_locations) {

        for (clcfp = pclcf->predicate_locations; *clcfp; clcfp++) {

            /* ... */

            vv = ngx_http_get_flushed_variable(r, (*clcfp)->predicate - 1);
            if (vv == NULL) {
                return NGX_ERROR;
            }

            if (vv->len && (vv->len != 1 || vv->data[0] != '0')) {
                r->loc_conf = (*clcfp)->loc_conf;

                /* ... look up nested locations, then NGX_OK ... */
            }
        }
    }

    return rc;

src/http/ngx_http_core_module.c lines 1460–1533

In short:

  1. An exact match (=) in the prefix tree ends it.
  2. On a prefix match, nested locations inside that location are searched first, recursively.
  3. If the longest prefix has ^~ (noregex), both regexes and predicates are skipped.
  4. Otherwise regexes are tried in config order and the first match is used. Even after finding the longest prefix, a matching regex wins.
  5. If no regex matches, predicates are evaluated in config order and the first true one is used.
  6. If there is still nothing, the prefix hung up in step 2 remains; if there was no prefix either, the server's default configuration stays.

The order of choosing a location. 1. Search the prefix tree. Exactly equal to an = location: decided right away with NGX_OK. On a prefix match, find the longest prefix and also search its nested locations. If that prefix is marked ^~, the regexes are skipped and it is chosen. For a plain prefix, or if nothing matched, 2. try regex locations one by one in config order and use the first match. If none matches, 3. use the first predicate location (1.31.x) whose value is neither empty nor 0. If none, the longest prefix location, or the server's default config if there is none. Whichever path, r->loc_conf is finally set to the chosen location's loc_conf. Green boxes mean "decided here". For regexes and predicates, the order in the config is the priority.

Example 2: who wins among overlapping locations

This is the inside of http {} from the config in the example folder ex/u09-location-match. The response body tells which location was chosen.

    # $is_beta is "1" with ?beta=1, otherwise "0"
    map $arg_beta $is_beta {
        default 0;
        1       1;
    }

    server {
        listen 8080;

        location = /img             { return 200 "= /img\n"; }
        location /img/              { return 200 "/img/\n"; }
        location /img/icons/        { return 200 "/img/icons/\n"; }
        location ^~ /img/static/    { return 200 "^~ /img/static/\n"; }
        location ~ \.png$           { return 200 "~ regex (png)\n"; }

        # predicate location, new in 1.31.x (not in 1.30.x)
        location $is_beta           { return 200 "predicate (is_beta=$is_beta)\n"; }

        location /                  { return 200 "/\n"; }
    }

run.sh sends ten URIs with curl, then picks only the test location and using configuration lines out of the debug log for three requests.

=== 1. which location wins ===
/img                   -> = /img
/img/a.jpg             -> /img/
/img/a.png             -> ~ regex (png)
/img/icons/a.gif       -> /img/icons/
/img/icons/a.png       -> ~ regex (png)
/img/static/a.png      -> ^~ /img/static/
/other                 -> /
/other?beta=1          -> predicate (is_beta=1)
/img/a.png?beta=1      -> ~ regex (png)
/img/a.jpg?beta=1      -> predicate (is_beta=1)
=== 2. debug log: GET /img/icons/a.png ===
*12 test location: "/"
*12 test location: "img/"
*12 test location: "static/"
*12 test location: "icons/"
*12 test location: ~ "\.png$"
*12 using configuration "\.png$"
=== 3. debug log: GET /img/static/a.png ===
*13 test location: "/"
*13 test location: "img/"
*13 test location: "static/"
*13 using configuration "/img/static/"
=== 4. debug log: GET /other?beta=1 ===
*14 test location: "/"
*14 test location: "img/"
*14 test location: ~ "\.png$"
*14 test location: "$is_beta"
*14 using configuration "$is_beta"

Easy to get wrong: where config order matters and where it doesn't

Prefix locations are sorted into a tree, so the order you write them in means nothing. Example 2 wrote location / last, and it would give the same results anywhere. Regex and predicate locations are the opposite: the order you write them in is their priority. Put location ~ /img/ above location ~ \.png$ and /img/a.png goes to the upper one.

5. The rewrite module and variables — a small interpreter plugged into a phase

The rewrite module handles rewrite, return, set, if and break. These directives are compiled into a "code array" when the config is read, and at request time the array is run from the start.

    e->ip = rlcf->codes->elts;
    e->request = r;
    e->quote = 1;
    e->log = rlcf->log;
    e->status = NGX_DECLINED;

    while (*(uintptr_t *) e->ip) {
        code = *(ngx_http_script_code_pt *) e->ip;
        code(e);
    }

    return e->status;
}

src/http/modules/ngx_http_rewrite_module.c lines 172–184

e->ip points at "the code to run now" (the name comes from a CPU's instruction pointer). Each entry of the array is a struct that starts with a function pointer; the loop just calls that function, and each code function moves e->ip forward by its own size. It stops at a slot that is 0 (NULL). If e->status is still its initial NGX_DECLINED, the rewrite checker moves on to the next slot.

The line rewrite ^/a/(.*)$ /b/$1 last; becomes a regex start code, copy codes that build the replacement string, and a regex end code. A fourth argument of last sets last = 1; break sets regex->break_cycle = 1 and last = 1 (lines 363–386). With last = 1 one more NULL code is appended after the end code (lines 425–432), so if the regex matches, the location's remaining rewrite directives don't run. Up to here the two are the same; the difference is one flag set at request time.

    if (code->uri) {
        r->internal = 1;
        r->valid_unparsed_uri = 0;

        if (code->break_cycle) {
            r->valid_location = 0;
            r->uri_changed = 0;

        } else {
            r->uri_changed = 1;
        }
    }

src/http/ngx_http_script.c lines 1179–1190

Before sending us back, the POST_REWRITE checker counts.

    r->uri_changes--;

    if (r->uri_changes == 0) {
        ngx_log_error(NGX_LOG_ERR, r->connection->log, 0,
                      "rewrite or internal redirection cycle "
                      "while processing \"%V\"", &r->uri);

        ngx_http_finalize_request(r, NGX_HTTP_INTERNAL_SERVER_ERROR);
        return NGX_OK;
    }

    r->phase_handler = ph->next;

    cscf = ngx_http_get_module_srv_conf(r, ngx_http_core_module);
    r->loc_conf = cscf->ctx->loc_conf;

    return NGX_AGAIN;
}

src/http/ngx_http_core_module.c lines 1098–1115

r->uri_changes starts at NGX_HTTP_MAX_URI_CHANGES + 1, that is 11, when the request is created (ngx_http_request.c line 661; the constant 10 is at ngx_http_request.h line 12). When it reaches 0 the result is 500. r->loc_conf is reset to the server's default before going back because the location has to be found from scratch.

Example 3: last, break, a named location and an endless rewrite

The server {} block from the example folder ex/u09-rewrite-redirect. The root contains just one file, /b/x.txt ("static file /b/x.txt").

    server {
        listen 8080;

        # server-level rewrite (runs in the SERVER_REWRITE phase)
        rewrite ^/old/(.*)$ /b/$1 last;

        location /a/ {
            # last: change the URI and look up the location again
            rewrite ^/a/(.*)$ /b/$1 last;
        }

        location /brk/ {
            # break: only change the URI and stay in this location (the return below never runs)
            rewrite ^/brk/(.*)$ /b/$1 break;
            return 200 "never reached\n";
        }

        location /b/ {
            return 200 "location /b/: uri=$uri request_uri=$request_uri\n";
        }

        location /loop/ {
            # keeps rewriting to itself → 500 after 10 times
            rewrite ^/loop/(.*)$ /loop/$1 last;
        }

        location /try/ {
            try_files $uri @fallback;
        }

        location @fallback {
            return 200 "named @fallback: uri=$uri\n";
        }
    }
=== 1. responses ===
/a/x.txt       -> location /b/: uri=/b/x.txt request_uri=/a/x.txt  [200] 
/brk/x.txt     -> static file /b/x.txt  [200] 
/old/x.txt     -> location /b/: uri=/b/x.txt request_uri=/old/x.txt  [200] 
/try/none.txt  -> named @fallback: uri=/try/none.txt  [200] 
/loop/x        -> (error page) [500] 

Putting last and break side by side in the debug log makes the difference plain.

=== 2. debug log: GET /a/x.txt (rewrite ... last) ===
*7 generic phase: 0
*7 rewrite phase: 1
*7 http script regex: "^/old/(.*)$"
*7 test location: "/brk/"
*7 test location: "/b/"
*7 test location: "/a/"
*7 using configuration "/a/"
*7 rewrite phase: 3
*7 http script regex: "^/a/(.*)$"
*7 http script regex end
*7 rewritten data: "/b/x.txt", args: ""
*7 post rewrite phase: 4
*7 uri changes: 11
*7 test location: "/brk/"
*7 test location: "/b/"
*7 using configuration "/b/"
*7 rewrite phase: 3
*7 http finalize request: 0, "/b/x.txt?" a:1, c:1
=== 3. debug log: GET /brk/x.txt (rewrite ... break) ===
...
*8 using configuration "/brk/"
*8 rewrite phase: 3
*8 http script regex: "^/brk/(.*)$"
*8 http script regex end
*8 rewritten data: "/b/x.txt", args: ""
*8 post rewrite phase: 4
*8 generic phase: 5
...
*8 content phase: 17
*8 http finalize request: 0, "/b/x.txt?" a:1, c:1

The paths of last and break, from Example 3's debug log. On the left, GET /a/x.txt (rewrite … last, *7): in rewrite phase: 1 the server-level ^/old/ doesn't match; FIND_CONFIG gives using configuration "/a/"; in rewrite phase: 3, rewritten data: "/b/x.txt" sets r->uri_changed = 1. post rewrite phase: 4 logs uri changes: 11 and sends it back to FIND_CONFIG, which gives using configuration "/b/", and the return 200 in rewrite phase: 3 answers location /b/: uri=/b/x.txt. On the right, GET /brk/x.txt (rewrite … break, *8): FIND_CONFIG gives using configuration "/brk/"; in rewrite phase: 3, rewritten data: "/b/x.txt" sets r->uri_changed = 0, and the return below never runs. post rewrite phase: 4 just passes through, it goes on from generic phase: 5 to content phase: 17, and the static module sends the file for the changed URI, static file /b/x.txt. The two debug logs above, side by side. The only difference is whether post rewrite phase sends the request back.

The endless rewrite (/loop/x) prints eleven lines from uri changes: 11 down to uri changes: 1, then ends like this.

*10 uri changes: 2
*10 uri changes: 1
*10 rewrite or internal redirection cycle while processing "/loop/x"

The log prints the value before decrementing. From 11 down to 2 it went back ten times, and on the eleventh, which printed 1, the count hit 0 and gave 500. Processing again with a changed URI is allowed up to ten times per request, and that count is shared with the internal redirects and named locations of section 6.

Variables — names become numbers at config time, values are computed once per request

return 200 "... uri=$uri ..." is also compiled at config time. The name $uri becomes an integer index through ngx_http_get_variable_index(). A variable is defined by a table of names and "functions that compute the value".

    { ngx_string("uri"), NULL, ngx_http_variable_request,
      offsetof(ngx_http_request_t, uri),
      NGX_HTTP_VAR_NOCACHEABLE, 0 },

src/http/ngx_http_variables.c lines 247–249

This is one line of ngx_http_core_variables[] (from line 168). ngx_http_variable_request returns the ngx_str_t at the offset it receives as data (here r->uri). Looking at ngx_http_get_indexed_variable() (lines 618–665), which fetches the value at request time, each request has an r->variables[] array, and the value is computed with get_handler only the first time it is asked for and stored in that slot. After that it is returned straight away by checking the valid bit — lazy evaluation plus a cache. $uri, though, carries NGX_HTTP_VAR_NOCACHEABLE. The source doesn't write down why, but rewrites and internal redirects change r->uri in the middle of a request, so reusing a value computed once could be wrong. ngx_http_get_flushed_variable() (lines 668–685) clears such slots and recomputes them, and the predicate locations of section 4-4 fetched their values through this function.

6. Again with another URI — internal redirects and named locations

Besides rewrite ... last there are two more ways to "go again with another URI", and the three go back to different slots of ph[].

ngx_http_internal_redirect() decrements and checks uri_changes (lines 2625–2635), changes the URI, and starts over.

    /* clear the modules contexts */
    ngx_memzero(r->ctx, sizeof(void *) * ngx_http_max_module);

    cscf = ngx_http_get_module_srv_conf(r, ngx_http_core_module);
    r->loc_conf = cscf->ctx->loc_conf;

    ngx_http_update_location_config(r);

    /* ... */

    r->internal = 1;
    r->valid_unparsed_uri = 0;
    r->add_uri_to_alias = 0;
    r->main->count++;

    ngx_http_handler(r);

    return NGX_DONE;
}

src/http/ngx_http_core_module.c lines 2651–2671

It clears the state modules attached to this request (r->ctx), resets r->loc_conf to the server default, and with r->internal = 1 calls ngx_http_handler() again. As section 3 showed, an internal request runs from server_rewrite_index; POST_READ is not repeated. Also worth noting: it does not create a new ngx_http_request_t but reuses the same struct. httpd created a new request_rec for each internal redirect and linked them with r->prev (httpd Part 10, section 6). Callers include the index module, try_files ending in a URI, error_page with a URI target (ngx_http_special_response.c line 631), and upstream's X-Accel-Redirect (ngx_http_upstream.c line 3163).

Requesting / from Example 1's server makes the index module do an internal redirect to /index.html.

=== 4. debug log: GET / (index -> internal redirect) ===
status=200
...
*4 generic phase: 13
*4 content phase: 14
*4 internal redirect: "/index.html?"
*4 rewrite phase: 1
*4 test location: "/"
*4 test location: "admin/"
*4 using configuration "/"
*4 rewrite phase: 3
...
*4 content phase: 17
*4 http finalize request: 0, "/index.html?" a:1, c:2
*4 http finalize request: -4, "/index.html?" a:1, c:1

The internal redirect happens at content phase: 14 (index), and the next line is rewrite phase: 1. generic phase: 0 does not appear again. In the first round, / ended after a single test location: "/" (the URI is as long as the node name, so NGX_AGAIN); in the second round, /index.html is longer, so it went down into tree, compared against admin/, and stayed with /. The c:2 → c:1 at the end is the r->main->count (reference count) that the redirect raised coming back down.

ngx_http_named_location() finds by name, so it skips FIND_CONFIG. Once it finds the name in cscf->named_locations (split off in section 4-2), it switches r->loc_conf straight to that location and runs from r->phase_handler = cmcf->phase_engine.location_rewrite_index (3 in Example 1) (lines 2720–2735). The URI is not changed. In Example 3's /try/none.txt log, this is what follows try_files in PRECONTENT (generic phase: 12).

*9 generic phase: 12
*9 test location: "@fallback"
*9 using location: @fallback "/try/none.txt?"
*9 rewrite phase: 3
*9 http finalize request: 0, "/try/none.txt?" a:1, c:2

It goes straight to rewrite phase: 3 without a using configuration (FIND_CONFIG's log line), and the response's $uri was still the original /try/none.txt.

The slots processing re-enters at. A new request (ngx_http_handler, internal=0) starts at ph[0] POST_READ; an internal redirect (ngx_http_internal_redirect — URI from index · error_page · try_files) at ph[1] SERVER_REWRITE (server_rewrite_index); rewrite ... last (next of post_rewrite_phase) at ph[2] FIND_CONFIG; a named location (ngx_http_named_location — @name from error_page · try_files) at ph[3] REWRITE (location_rewrite_index). rewrite ... break (uri_changed=0) does not go back and carries on from ph[5] PREACCESS. Slot numbers follow Example 1's array. All three ways of going back decrement r->uri_changes by one and end in 500 when it reaches 0.

Easy to get wrong: an internal redirect repeats the access checks

Going back to an earlier slot means passing through every phase after it again. The / → /index.html redirect went through the ACCESS slots again too. httpd skipped access checks when the configuration object was the same, but nginx's access checker skips them only for subrequests (r != r->main) (lines 1125–1128). An internal redirect reuses the same struct (r == r->main), so the checks run again.

What this post leaves out

Recap

Concept One-line summary
phase 11 slots that divide request processing. The core sets the order; modules put functions into cmcf->phases[i].handlers
ph[] array the per-phase arrays flattened into one at config time. Each slot has checker, handler, next; the end is checker == NULL
copied backwards within a phase, modules later in the module list are called first (index before static)
ngx_http_core_run_phases() a loop that calls the checker of slot r->phase_handler until one returns NGX_OK. After a pause it resumes at the same slot
checker reads handler return values differently per phase (OK also ends it for rewrite; access depends on satisfy)
LOG phase outside ph[]. ngx_http_log_request() calls it when the request is released
location tree prefixes sorted into a binary tree, with shared prefixes as tree children. Node names are what is left after the prefix
priority = → longest prefix (nested first) → stop if ^~ → regexes (config order) → predicates (config order, 1.31.x) → longest prefix
content handler proxy_pass and friends plant it in clcf->handler; after FIND_CONFIG it becomes r->content_handler
rewrite module compiled into a code array at config time, run by a function-pointer loop per request. last goes to FIND_CONFIG, break stays
variables names become indexes at config time. Values are computed on first use and cached; changing ones like $uri are recomputed
internal redirect reuses the same struct, from server_rewrite_index. Named locations start at location_rewrite_index. Ten times in total

What to look at next

Once a handler has been chosen in the CONTENT phase, the response is produced and flows out. The static module opens the file, puts it in an ngx_buf_t and hands it to ngx_http_send_header() and ngx_http_output_filter(), after which the header filter, gzip, chunked and the write filter follow as a chain of function pointers. That is what happened between content phase: 17 and http finalize request in this post's examples. It continues in Part 10: Filters and Static Files — How a Response Flows Out. What happens after proxy_pass becomes the content handler is in Part 11.

Other servers solve the same problem like this. httpd steps through phases with named hooks and RUN_FIRST/RUN_ALL rules, and for every request does a "walk" that turns the URL into a disk path and <Directory> settings (httpd Part 10: Request Processing). Tomcat's Mapper also keeps names sorted and finds the longest path by binary search — the same idea as nginx's prefix tree, except that it searches again after cutting the URI back one segment at a time (Tomcat Part 7: CoyoteAdapter and the Mapper). The chain a request passes through is worth comparing with Tomcat Part 8's pipelines and valves, and turning a URL into a Java method with Spring Part 22: Handler Mapping and Adapters.

More posts