nginx
nginx Redirects: return vs rewrite, $request_uri vs $uri
When to use return and when rewrite in nginx, why $request_uri keeps the query string and $uri does not, how location modifiers pick a winner, and the redirect loops to avoid.
Most nginx redirect bugs come from copying the wrong one of two similar-looking lines. rewrite ^/(.*)$ https://example.com/$1 permanent; and return 301 https://example.com$request_uri; both send a 301. They differ in cost, in what happens to the query string, and in which status codes they can send. Use return for every redirect you can express as a fixed URL plus variables, and keep rewrite for cases where you need regex captures from the path. Almost everything else in this post follows from what those two directives do with the URL. Every directive quote below comes from the official nginx module docs, and every config block was generated by the library behind our nginx redirect generator.
This post covers the nginx side only. For what 301, 302, 307 and 308 do to search rankings and caches, see 301 vs 302 redirects. We won’t repeat that table here.
return: stop and answer
The rewrite module docs describe return in one line: it “stops processing and returns the specified code to a client.” You can give it a redirect URL for codes 301, 302, 303, 307 and 308, and “a response body text and redirect URL can contain variables.” It doesn’t run a regex or re-search locations. It just answers.
Two version notes in those docs matter if you still run an old nginx: “The code 307 was not treated as a redirect until versions 1.1.16 and 1.0.13,” and “the code 308 was not treated as a redirect until version 1.13.0.”
The catch is that return appends nothing. If the target is /about and the request was /about-us?ref=mail, the client goes to /about and ?ref=mail is gone, unless you add it back yourself with $is_args$args. The core module docs define $is_args as “? if a request line has arguments, or an empty string otherwise,” so the pair adds a ? only when there is something after it.
rewrite: match, capture, and maybe redirect
rewrite regex replacement [flag] changes the request URI when the regex matches. Only two of its four flags send a redirect. redirect “returns a temporary redirect with the 302 code” and permanent “returns a permanent redirect with the 301 code.” (last and break rewrite internally and never tell the client.) That means rewrite can’t send a 307 or 308. If you need one of those, you need return.
The query string works the other way round from return. With rewrite, the original arguments come along automatically. The docs say that if the replacement contains new arguments, “the previous request arguments are appended after them,” and that “putting a question mark at the end of a replacement string avoids having them appended.” nginx’s own converting rewrite rules page relies on this. Its pre-0.9.1 fallback is rewrite ^ http://www.example.org$request_uri?;, where the trailing ? stops the arguments from being added a second time.
Side by side:
return | rewrite … permanent/redirect | |
|---|---|---|
| Status codes | Any; redirect URL for 301, 302, 303, 307, 308 | 301 or 302 only |
| Needs a regex | No | Yes |
| Query string | Dropped unless you add $is_args$args | Kept unless the replacement ends in ? |
Captures ($1) | Only from an enclosing regex location or if | From its own pattern |
The same nginx page is blunt about the common host-redirect mistake. It shows an if ($http_host = example.org) { rewrite (.*) http://www.example.org$1; } block and calls it “a wrong, cumbersome, and ineffective way.” Its fix is a separate server block containing return 301 http://www.example.org$request_uri;.
$request_uri vs $uri
These two variables look alike, but they hold different things:
$request_uriis the “full original request URI (with arguments)”: the path and query string exactly as the client sent them, still percent-encoded.$uriis the “current URI in request, normalized.” It has no query string, and it “may change during request processing,” for example after an internal redirect or an index file lookup.
“Normalized” means the same thing it does for location matching. The core docs say matching happens “after decoding the text encoded in the %XX form, resolving references to relative path components . and .., and possible compression of two or more adjacent slashes into a single slash.”
So https://$host$request_uri is a lossless forward. /search?q=a%20b arrives exactly as sent. Building the same redirect from $uri loses ?q=a%20b completely and puts a decoded space into the path. Use $request_uri when you pass a whole URL along to another host or scheme. Use $uri, or captures matched against it, when you are deciding which path someone asked for.
How location modifiers pick a winner
Every path-level redirect lives in a location, so it helps to know which one nginx actually picks. The core docs give the order:
location = /pathis an exact match: “if an exact match is found, the search terminates.”- Otherwise nginx finds the longest matching prefix and remembers it. If that prefix has
^~, regular expressions are not checked. - Otherwise the regex locations (
~case-sensitive,~*case-insensitive) are checked “in the order of their appearance in the configuration file,” and the first match wins, even over the remembered prefix.
The docs’ own example shows the trap: /documents/1.jpg goes to the ~* \.(gif|jpg|jpeg)$ regex, not to location /documents/. The regex wins because the prefix has no ^~. If you put a folder redirect in a plain prefix location, any image or .php regex further down the file will take requests away from it. That’s why our generator writes folder redirects as location ^~.
None of this applies to rewrite and return placed directly in server {}. Per the rewrite docs, server-level rewrite-module directives “are executed sequentially” before nginx searches for a location.
What our generator emits
Here are four real outputs from generateNginx() in our library. Headers and comments are trimmed.
Force HTTPS and drop www, in one hop. The port-80 block sends both hostnames straight to the canonical HTTPS host, so http://www. doesn’t pass through https://www. on the way:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # update path
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # update path
return 301 https://example.com$request_uri;
}
The certificate on the second block has to cover www.example.com. The TLS handshake happens before nginx can send any redirect, so a certificate error stops the browser before it ever sees the 301.
One page and one folder, as 301s. The exact rule uses return with $is_args$args. The folder rule uses rewrite, which carries the query string over by itself, plus an exact rule for the bare /blog, which the /blog/ prefix doesn’t match:
location = /about-us {
return 301 /about$is_args$args;
}
location = /blog {
return 301 /articles/$is_args$args;
}
location ^~ /blog/ {
rewrite ^/blog/(.*)$ /articles/$1 permanent;
}
The same folder move as a 308. rewrite can’t send 308, so the generator nests a regex location to get the capture and answers with return:
location ^~ /api/v1/ {
# rewrite can only send 301/302, so 308 uses return with a nested regex capture
location ~ ^/api/v1/(.*)$ {
return 308 /api/v2/$1$is_args$args;
}
}
This is the case where 307 and 308 earn their place. RFC 9110 says a user agent following a 301 or 302 “MAY change the request method from POST to GET” (§15.4.2, §15.4.3). For 307 it says the user agent “MUST NOT change the request method” (§15.4.8), and the section introduction explains that 307 and 308 “were later added to unambiguously indicate method-preserving redirects” (§15.4). An API client POSTing to /api/v1/orders needs the 308.
A regex rule as a 307. At server level the generator uses if plus return:
if ($uri ~ ^/checkout/(.*)$) {
return 307 /pay/$1$is_args$args;
}
One honest limitation: for 307 and 308 regex rules (this one and the nested-location folder form above), the capture comes from $uri, which is already percent-decoded. If the captured part of the path contained an encoded character such as %20, it goes into the new URL decoded. We’ve noted this as a known gap and not fixed it yet. For ordinary ASCII slugs it makes no difference. If your old URLs have encoded characters in the captured part, check the result with the redirect checker before going live.
Bulk redirects need a map, and the map goes in http {}
For hundreds of one-to-one moves, a map beats hundreds of location blocks. The map module docs list its context as http only. If you paste it inside server {}, nginx -t rejects it. Here’s what our generator produced from a pasted list:
# http {} context
map $request_uri $redirect_target {
default "";
/old-page /new-page;
/page?id=5 /products/5;
/new-page /final;
}
# inside server {}
if ($redirect_target) {
return 301 $redirect_target;
}
Matching on $request_uri lets /page?id=5 have its own key, which a location can never do because locations don’t see the query string. The trade-off is that the match is exact, so /page?id=5&utm_source=x is a different key and falls through. Two things in the pasted list got flagged. The paste also contained /Old-Page /elsewhere, and that line was rejected, because the map docs say “strings are matched ignoring the case,” so it collides with /old-page. nginx refuses duplicates at load time too: the map module source raises conflicting parameter. And /old-page → /new-page → /final was flagged as a chain, with the suggestion to point line 1 straight at /final.
”If is evil,” except for return
The old nginx wiki page “If is Evil… when used in location context” (archived) warns that if in a location “in some cases … doesn’t do what you expect but something completely different instead.” It then lists the exceptions: “The only 100% safe things which may be done inside if in a location context are: return …; rewrite … last;.” It also says ifs can be moved to server level, “where it’s safe as only other rewrite module directives are allowed within it.” The if blocks our generator writes all sit at server level and contain nothing but return.
Loops and other ways to break a site
- One block for both ports. A
serverwithlisten 80;andlisten 443 ssl;whose only rule isreturn 301 https://$host$request_uri;also redirects HTTPS requests to themselves, forever. Keep the port-80 redirect in its own block, as in the first example above. - A target inside its own prefix. A folder rule from
/docs/to/docs/v2/matches its own output:/docs/v2/xbecomes/docs/v2/v2/x, and it keeps going. Our generator warns: Target “/docs/v2/” is inside the “/docs/” prefix — this loops forever. nginx won’t catch it, because each hop is a fresh request from the browser. - Query strings in
location.location = /x?y=1never matches. The generator treats it as an error and points you to a$request_urimap. - Trailing-slash rules. Our “remove” rule is
rewrite ^(/.+)/$ $1 permanent;. The.+means the root/never matches. The “add” rule skips any path containing a dot, so/style.cssdoesn’t become/style.css/. Both run at server level, before your location rules, so write any later exact rules in the slash-normalized form.
Before you reload
- Build the rules in the nginx redirect generator. It runs in your browser, and nothing you type is sent anywhere. Fix anything flagged red.
- Put the
mapinhttp {}and the rest in yourserverblock, then runsudo nginx -tbefore reloading. - Run a handful of old URLs, including ones with query strings, through the redirect checker and confirm each ends in one hop with the code you expected. If a code surprises you, the HTTP status code reference explains it.
Moving the same site to Apache? The .htaccess generator builds the equivalent RewriteRule set, and the regex tester is the quickest way to check a capture pattern against real paths before it goes into either one.