11.1 The Rewrite Engine
Three directives — return, rewrite, set — run before any content handler, before access rules, before almost everything. That earliness is their power and their danger.
Advanced
What you will learn
The rewrite phase: where these directives run, and why
returnbypassesauth_basic(the Module 6 receipt, explained).return— the cheapest redirect and the cheapest response.rewritewith captures, and the flag you choose on purpose:lastvsbreak(vs plain redirects).$urivs$request_uri— the rewritten world vs the client's world, proven in one log line.rewrite_log on— the engine narrating its own work.
1. The phase model (finally, the full story)
Lesson 6.4 left you a receipt: a
$ssl_session_reused flag that survives redirects.
rewrite has an equivalent paper trail: the
$uri/
$request_uri pair you will meet in §3.
1. server/vhost selection (listen, server_name)
2. location matching (Module 2's algorithm)
3. REWRITE PHASE <- return / rewrite / set live HERE
4. access phase <- auth_basic, allow/deny, auth_request
5. content handler <- proxy_pass, fastcgi, static, try_files
6. log phaseA
return in step 3 finishes the request with its own response — steps 4 and 5 never run. No auth challenge, no upstream, no file lookup. This is not a bug; it is the documented phase order, and it is why rewrites and returns are both your fastest tools and your quietest privilege-escalation paths. (Corollary from Module 7: rate limiting runs in the
preaccess phase — after rewrite, before access — so a
return in rewrite also skips
limit_req. Breadcrumbs for your security review.)
2.
return — cheapest first
location = /moved {
return 301 /destination;
}No regex, no capture, no second pass through location matching.
return supports redirect codes (301/302/307/308) with a URL, or any other code with optional text —
return 200 "you have arrived at $uri\n" is a complete, upstream-less response. The receipt (real capture):
HTTP/1.1 301 Moved Permanently
Server: nginx/1.30.5
...
modern page for uri=/destination <- after following itHouse rule that scales:
if you don't need to inspect or capture the URI,
return
. Only reach for
rewrite
when the regex is load-bearing.
3.
rewrite, captures, and the two flags that matter
location /legacy/ {
rewrite ^/legacy/(.+)$ /modern/$1 last;
}
location /modern/ {
add_header X-Modern-Path "yes";
return 200 "modern page for uri=$uri original=$request_uri\n";
}The regex captures
(.+) from
/legacy/widget and builds
/modern/widget. The flag decides what happens next — and the choice is the whole lesson:
Flag | Meaning | Consequence |
|---|---|---|
(none) | finish the rewrite directives; if the URI changed, re-run location matching | internal — the client sees only the final result (no
|
| stop this location, re-run location matching with the new URI | a different location usually wins; internal, client sees only the result |
| stop rewrite processing, stay in this location | the content handler serves the rewritten URI from this location's root |
The difference, proven with the same regex pointed at the same file (real captures):
location /breakdemo/ {
root www/main;
rewrite ^/breakdemo/(.*)$ /docs/$1 break; # stays HERE
}
location /lastdemo/ {
rewrite ^/lastdemo/(.*)$ /modern/$1 last; # re-matches
}break -> 200 (handbook.pdf served FROM /breakdemo/, via its own root)
last -> modern page for uri=/modern/handbook.pdf original=/lastdemo/handbook.pdf
break served the file's bytes because the
static handler inside
/breakdemo/ looked up the rewritten
/docs/handbook.pdf against
root www/main.
last restarted matching,
/modern/ won, and its
return answered instead.
Two sharp edges to keep in mind:
breakhalts rewrite processing in context — asetorreturnafter it in the same block never runs. (The/breakdemo/example above deliberately has no directives after its rewrite.)lastcan loop: the engine re-matching a URI that keeps matching the same rewrite re-runs it, up to 10 cycles, then500. The/self/shape (a regex that matches its own output) is the classic way to meet the limit.
And the
/lastdemo/handbook.pdf receipt above shows the pair of variables that keep you sane:
$uri is the
current, rewritten URI;
$request_uri is the
original, verbatim request (path + args), immune to every rewrite. Logging both —
$uri $request_uri side by side — turns every rewrite bug into a one-line diagnosis.
4. The engine narrates itself
One directive turns the rewrite phase into an observable system:
location /shuttle/ {
rewrite_log on;
rewrite ^/shuttle/(.*)$ /modern/$1 last;
}
rewrite_log writes to the
error log at
notice level — which is why the example config's
error_log runs
notice while production usually stays at
warn. The narration from a real run:
2026/09/23 01:04:44 [notice] 3430#3430: *43 rewritten data: "/modern/thing", args: "",
client: 127.0.0.1, server: rewrite.example.test,
request: "GET /shuttle/thing HTTP/1.1", host: "127.0.0.1:9300"That single line answers: what came in, what it became, whether args survived, which server/location context ran it. When a rewrite misbehaves in production,
rewrite_log on at
notice in the one suspicious location is the surgical instrument — not a global log-level change.
5. Why
set belongs to this family
set $tier free; looks like a variable assignment but runs
in the rewrite phase, before content handlers exist. That's why it can feed
add_header and
proxy_set_header (both evaluated later), and why an
if containing only
set/
return behaves predictably — all of it is one phase, evaluated in order. Lesson 11.2 builds the whole
if debate on this fact.
Try it yourself
nginx -t && nginx -s reload— the configs above run on:9300in this lesson, verbatimcurl -i http://127.0.0.1:9300/legacy/widget— noLocationheader, body shows both URIstail /var/log/nginx/error.log | grep rewritten— the narration receiptsFlip the
/breakdemo/flag tolastandnginx -s reload— watch it start answering like/lastdemo/
Q&A
Q:
rewrite
without a flag — when does it still produce an external redirect? A: When the replacement string itself starts with
http://,
https://, or
$scheme — nginx answers with a 302 automatically (301 still requires the
permanent flag). A plain-path replacement never redirects: it re-matches internally, and the
client's bookmark must change case — URL rebranding, trailing-slash enforcement — is exactly when you should reach for
permanent (301) for stable changes or
redirect (302) while experimenting; clients cache 301s aggressively.
Q: Why did my query string survive the rewrite? A: The replacement string only replaces the path; args append automatically unless you end the replacement with
? (which deliberately drops them).
Q: Can I rewrite to a different
server
block? A:
last re-runs
location matching in the same server. Crossing servers means a real redirect — or
internal proxied hops, which is X-Accel-Redirect's territory (Lesson 11.5).
Takeaways
Rewrite-family directives run in the rewrite phase — before access rules and content handlers — so a bare
returnskipsauth_basic,limit_req, and the rest. Powerful; audit accordingly.Flags are intent:
last= re-match locations,break= stay here and serve the rewritten URI, none = client-visible redirect.breakalso stops any later rewrite directives.$uriis the rewritten world,$request_urithe client's — log both and rewrites stop being mysterious.rewrite_log on+error_log noticegives you per-rewrite receipts straight from the engine.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.