You add deny all; to a location, reload, and the request still returns 200. You move a set directive above the line that reads it, and nothing changes. You put rewrite_by_lua_block between two set directives expecting it to run between them, and it runs after both. Every one of these looks like a bug in NGINX. None of them is. They are all the same misunderstanding about NGINX directive execution order. The order directives appear in your configuration file has almost nothing to do with the order they run.
This is what NGINX directive execution order actually means. NGINX processes every request through a fixed pipeline of eleven phases, and a directive does not run where you wrote it. It runs when its phase comes up. Once you can name the phases, the surprises above stop being surprises. They become predictions you can make before you reload. This guide walks the pipeline end to end. Execution order is far easier to observe than to describe, so every section below is a configuration you can reload and hit with curl. The examples use four well-established NGINX modules: echo, set-misc, lua, and headers-more. GetPageSpeed packages all of them for every major distribution. Printing a variable mid-pipeline is the only honest way to show what ran when.
Following along? The examples below need those four modules. Install them now so the snippets work as you read (full per-distro steps, including the RHEL load order, are in Installing the modules used in this guide):
sudo dnf install https://extras.getpagespeed.com/release-latest.rpm sudo dnf install nginx-module-echo nginx-module-set-misc nginx-module-lua nginx-module-headers-more
The eleven phases
NGINX declares its request-processing phases as a single enumeration in src/http/ngx_http_core_module.h. This is the authoritative list, in execution order:
| # | Phase | What runs here |
|---|---|---|
| 1 | post-read | set_real_ip_from / real_ip_header |
| 2 | server-rewrite | rewrite, set, return, if at server level |
| 3 | find-config | NGINX selects the location. No directives run here. |
| 4 | rewrite | rewrite, set, if inside a location; rewrite_by_lua; set_by_lua; set-misc directives; more_set_input_headers |
| 5 | post-rewrite | Internal redirect loop guard. No directives run here. |
| 6 | preaccess | limit_req, limit_conn, degradation |
| 7 | access | allow / deny, auth_basic, auth_request, access_by_lua |
| 8 | post-access | Resolves satisfy any / satisfy all |
| 9 | precontent | try_files, mirror, precontent_by_lua |
| 10 | content | The content handler: proxy_pass, echo, content_by_lua, root / index / autoindex |
| 11 | log | access_log, log_by_lua |
Two entries deserve an early flag. find-config is where NGINX finally decides which location block applies. Everything in phases 1 and 2 therefore happens before your location exists as a concept. precontent is the modern name for what older material calls the “try-files phase”. NGINX renamed and generalized it in 1.13.4, so any guide still calling it try-files predates mirror and precontent_by_lua.
Configuration order is not execution order
Here is the shortest possible proof. The echo directive runs in the content phase; set runs in the rewrite phase. Write them in the “wrong” order on purpose:
location = /order {
echo "value is: $foo";
set $foo "set in the rewrite phase";
}
$ curl -s http://localhost/order
value is: set in the rewrite phase
The set line sits below the echo line, yet its value is already there when echo runs. The rewrite phase (4) completed before the content phase (10) began. Reading this configuration top to bottom tells you nothing useful; reading it phase by phase tells you everything.
Why return beats deny
This is the single most consequential ordering fact in NGINX, and it has real security impact. return belongs to the rewrite module and runs in the rewrite phase (4). deny belongs to the access module and runs in the access phase (7). Rewrite comes first, so return wins and the access check never executes:
location = /short-circuit {
deny all;
return 200 "rewrite phase won\n";
}
$ curl -s -w "[HTTP %{http_code}]\n" http://localhost/short-circuit
rewrite phase won
[HTTP 200]
The deny all; is not a typo and not misplaced. It is simply unreachable, because the request was answered three phases earlier. Have you ever “hardened” a location with deny and found it wide open? Look for a return or a rewrite ... last in the same block. Therefore, treat any location that mixes return with access-control directives as a bug until proven otherwise.
Server-level rewrites happen before your location is chosen
Because find-config is phase 3, a rewrite at server level (phase 2) changes the URI before NGINX has picked a location. The location that ultimately handles the request is the one matching the rewritten URI, not the original one:
server {
rewrite ^/entry$ /target;
location = /entry {
echo "you reached /entry";
}
location = /target {
echo "server-rewrite ran before find-config chose this location";
}
}
$ curl -s http://localhost:8080/entry
server-rewrite ran before find-config chose this location
The location = /entry block is never entered, despite the client having requested exactly /entry. This is also why a set at server level is visible in every location: it ran before any of them.
Preaccess and access: two rejections, one location
Phases 6 and 7 are adjacent and easy to conflate, but they reject requests independently. Put a rate limiter (limit_req, preaccess) and an ACL (deny, access) in one location. The status code then depends on which phase rejects first:
# http context
limit_req_zone $binary_remote_addr zone=phasedemo:1m rate=1r/m;
# server context
location = /pre {
limit_req zone=phasedemo;
deny all;
}
limit_req_zone is valid only directly inside http { }; only limit_req itself may appear in a server or location block.
$ for i in 1 2 3; do curl -s -o /dev/null -w "request $i -> HTTP %{http_code}\n" http://localhost/pre; done
request 1 -> HTTP 403
request 2 -> HTTP 503
request 3 -> HTTP 503
The first request passes the rate limit, reaches the access phase, and is denied with 403. The next two exceed the limit and are rejected with 503 in the preaccess phase, before the access phase runs at all. Same location, same configuration, two different outcomes decided purely by phase order. Consequently, when you are diagnosing an unexpected status code, the status itself tells you which phase you are in.
Note also that the log phase (11) runs for all three requests. Rejections are not exceptions that skip the pipeline; they short-circuit to the end of it, and logging still happens.
precontent: try_files and friends
Phase 9 runs after access control passes, but before a content handler is selected. That is exactly the right place to decide what to serve. try_files lives here:
location = /tf {
try_files /nonexistent.html @fallback;
}
location @fallback {
echo "precontent phase fell through to the named location";
}
$ curl -s -w "[HTTP %{http_code}]\n" http://localhost:8080/tf
precontent phase fell through to the named location
[HTTP 200]
A common mistake here is expecting try_files to fall through to a prefix location such as /fallback.txt. It does not. Its non-final arguments are filesystem paths tested against root. Only a named location (@name) or a final =code acts as a fallback target.
The content phase has exactly one slot
The content phase behaves unlike every other phase. Most phases run a list of handlers in order. The content phase instead runs a single content handler if the location has one. Directives like echo, content_by_lua_block and proxy_pass all compete for that one slot. The last one NGINX parses wins, and the others are silently discarded:
location = /collide {
echo "echo set the content handler";
content_by_lua_block { ngx.say("lua set the content handler") }
}
location = /collide2 {
content_by_lua_block { ngx.say("lua set the content handler") }
echo "echo set the content handler";
}
$ curl -s http://localhost:8080/collide
lua set the content handler
$ curl -s http://localhost:8080/collide2
echo set the content handler
Reversing the two lines reverses the winner. There is no error and no warning; the losing directive simply never runs. This is why adding echo to a location that already has a proxy_pass appears to do nothing. The fix is a separate location, not a reordering.
Static file serving is the exception that confirms the rule. root, index, and autoindex register as ordinary content-phase handlers. They run only when the location has not claimed the single handler slot. Set a proxy_pass and static serving stops. It was not overridden; it simply never gets reached.
Where third-party directives actually run
Third-party modules do not get a private pipeline. They register into the same eleven phases, and knowing which one removes most of the mystery from mixed configurations.
Take rewrite_by_lua_block. It always runs after every directive from the standard rewrite module in the same location, no matter where you write it:
location = /lua-order {
set $marker "A";
rewrite_by_lua_block { ngx.var.marker = ngx.var.marker .. "-lua" }
set $marker "${marker}-B";
content_by_lua_block { ngx.say(ngx.var.marker) }
}
$ curl -s http://localhost/lua-order
A-B-lua
Both set directives ran in file order, A then B. Only then did the Lua handler append its suffix, despite sitting textually between them. All three are in phase 4. Within a phase, handlers run in module order, and the Lua module deliberately places itself at the end of the rewrite phase. That way it observes the final result of every plain rewrite directive.
The set-misc module also lands in the rewrite phase, which is what makes its output available to a content-phase directive:
location = /decode {
set_unescape_uri $clean $arg_p;
echo "raw: $arg_p";
echo "decoded: $clean";
}
$ curl -s 'http://localhost:8080/decode?p=%2Fvar%2Flog'
raw: %2Fvar%2Flog
decoded: /var/log
The headers-more module is the most instructive case, because its two headline directives live in completely different parts of the pipeline. more_set_input_headers registers a rewrite-phase (4) handler and rewrites the request headers, so later phases see the modified request. more_set_headers is not a phase handler at all; it is an output header filter that runs after the content phase has produced a response:
location = /hm {
more_set_input_headers "X-Tier: gold";
more_set_headers "X-Served-By: phase-demo";
echo "tier seen at content time: $http_x_tier";
}
$ curl -s -D- http://localhost/hm | grep -iE "X-Served-By|tier seen"
X-Served-By: phase-demo
tier seen at content time: gold
The body proves the input header was already rewritten by the time the content phase read $http_x_tier. The response header proves the output filter ran later, on the way out. Two directives from one module, separated by six phases and a filter chain.
Common execution-order pitfalls
Most NGINX directive execution order bugs reduce to a handful of patterns:
returnorrewrite ... lastin a location with access control. The rewrite phase (4) precedes access (7), so the access directives never run. Move them to a location the rewrite cannot short-circuit.- Expecting
ifto behave like a programming-language conditional. Theifdirective is a rewrite-phase construct. It cannot wrap adeny, alimit_req, or aproxy_passthe way its syntax suggests. Those belong to other phases. - Two content handlers in one location. Only the last-parsed one runs, silently. Split them into separate locations.
- Assuming a server-level
rewritepicks the original location. It runs before find-config, so the rewritten URI selects the location. - Reading a variable in a phase that runs before it is set. A
setin a location (phase 4) is not visible to areal_ipdecision (phase 1). When ordering matters, prove it with a runtime test. - Treating
try_filesarguments as locations. All but the last are filesystem paths; only@namedlocations work as fallbacks.
A related question is when a variable’s value is computed, as opposed to when a directive runs. Our companion guide to NGINX variables covers get handlers, caching, and subrequest isolation.
Installing the modules used in this guide
Every runnable example above depends on one of four NGINX modules. GetPageSpeed ships all of them as pre-built dynamic modules, so there is nothing to compile.
RHEL, CentOS, AlmaLinux, Rocky Linux, Amazon Linux
Add the repository once, then install:
sudo dnf install https://extras.getpagespeed.com/release-latest.rpm
sudo dnf install nginx-module-echo nginx-module-set-misc nginx-module-lua nginx-module-headers-more
On RHEL-based systems, load each module near the top of nginx.conf. The set-misc and lua modules build on the NGINX Development Kit, so load ndk_http_module.so first (the nginx-module-ndk package is pulled in automatically as a dependency):
load_module modules/ndk_http_module.so;
load_module modules/ngx_http_echo_module.so;
load_module modules/ngx_http_set_misc_module.so;
load_module modules/ngx_http_lua_module.so;
load_module modules/ngx_http_headers_more_filter_module.so;
Debian and Ubuntu
First, set up the GetPageSpeed APT repository, then install:
sudo apt-get update
sudo apt-get install nginx-module-echo nginx-module-set-misc nginx-module-lua nginx-module-headers-more
On Debian/Ubuntu, the package handles module loading automatically. No
load_moduledirective is needed.
Module pages with per-distro details:
For more on the directives used above, see our guides to the NGINX echo module and the NGINX Lua module.
Conclusion
Knowing the phase order is one thing; keeping every server’s configuration consistent with it as the config evolves is another. A single added return can silently strand an access rule that was correct yesterday. GetPageSpeed Amplify runs scheduled gixy scans across every host and ties findings to live NGINX runtime metrics. Drop-in compatible with the deprecated nginx-amplify-agent (EOL January 2026).
NGINX directive execution order follows one rule with no exceptions: a directive runs in its phase, not in its line. The eleven phases are fixed, they always run in the same sequence, and every directive, core or third-party, belongs to exactly one of them. That single fact explains a lot. It is why return defeats deny. It is why a server-level rewrite changes which location handles the request. It is why a rate limiter and an ACL return different status codes from one block. It is why two content handlers in a location leave one of them silently dead.
Does a configuration behave in a way the file does not seem to justify? Stop reading it top to bottom. Start asking which phase each line belongs to. Modules like echo, set-misc, lua, and headers-more make that question answerable at runtime rather than theoretically, by letting you print the pipeline’s state from inside it.

