An nginx redirect and an internal rewrite change different parts of the request flow. A redirect returns a status and Location for the client to consider, potentially causing a new request. An internal rewrite changes nginx’s current URI while processing the existing request. Choosing between them determines whether the client sees a new address, whether location selection runs again, and how the final handler receives path and query data.
When it breaks
Replacing an intended internal mapping with a redirect adds a client round trip and exposes a different URL. Replacing a permanent public move with an internal rewrite leaves clients using the old address. Neither return nor rewrite should be treated as a universal shorthand that always defaults to the same redirect behavior; explicit status codes and rewrite flags make the intended transition reviewable.
Status choice affects method semantics and caching. A 307 or 308 preserves the request method when automatically redirected, while 301 and 302 allow the historical POST-to-GET change. Permanent does not mean every client instantly updates every stored link, and temporary does not mean a response can never be cached. Cache directives, request context, and client behavior still matter.
Internal rewrites can also select an unexpected handler or repeatedly match themselves. The last flag starts another location search for the changed URI, whereas break continues processing in the current location after ending the current rewrite sequence. Query arguments can be appended or discarded according to the replacement syntax. Treating these distinctions as interchangeable can cause wrong files, duplicate parameters, or HTTP 500 loops.
Check it quickly
Run the script with host Python, Docker, and the pinned nginx image. The recorded environment used nginx 1.28.0 on Linux arm64 with Docker 29.3.1. It builds and syntax-checks a disposable configuration, publishes only a loopback port, tests real responses without automatic redirect following, and removes its own resources. A small static file makes the break handler distinguishable:
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import http.client,json,socket,subprocess,time,uuid
NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609'
PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True):
result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120)
if check:assert result.returncode==0,result.stderr
return result
def wait(predicate):
deadline=time.monotonic()+15
while time.monotonic()<deadline:
try:
if predicate():return
except OSError:pass
time.sleep(.1)
raise AssertionError('fixture did not become ready')
@contextmanager
def lab(configuration,files=None,backend=None):
name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend']
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration)
for path,content in (files or {}).items():
target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True);target.write_text(content)
docker('network','create',name)
if backend is not None:
(root/'backend.py').write_text(backend)
(root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n')
docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root))
docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1])
wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0)
(root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n')
docker('build','--network','none','-t',tags[0],str(root))
docker('run','--rm','--network',name,tags[0],'nginx','-t')
docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',tags[0])
details=json.loads(docker('inspect',containers[0]).stdout)[0]
binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort'])
def ready():
connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True
wait(ready)
def request(path,method='GET',headers=None,body=None):
connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5)
try:
connection.request(method,path,body=body,headers=headers or {})
response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read()
finally:connection.close()
yield containers[0],request
finally:
docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False)
def config(locations,extra=''):
return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n'
locations=r'''
location = /old { return 308 /destination$is_args$args; }
location = /destination { return 200 "$request_method|$uri|$args"; }
location /last/ { rewrite ^/last/(.*)$ /target/$1 last; }
location /break/ { rewrite ^/break/(.*)$ /target/$1 break; add_header X-Route break always; }
location /target/ { add_header X-Route target always; return 200 "$uri|$args"; }
location /append/ { rewrite ^/append/(.*)$ /target/$1?new=1 last; }
location /discard/ { rewrite ^/discard/(.*)$ /target/$1? last; }
location /loop/ { rewrite ^ /loop/again last; }
'''
with lab(config(locations,'absolute_redirect off;'),{'target/item':'static-file'}) as (container,request):
status,headers,body=request('/old?x=1',method='POST',body='public-body')
assert status==308 and headers['location']=='/destination?x=1'
status,headers,body=request(headers['location'],method='POST',body='public-body');assert status==200 and body==b'POST|/destination|x=1'
status,headers,body=request('/last/item?x=1');assert status==200 and headers['x-route']=='target' and body==b'/target/item|x=1'
status,headers,body=request('/break/item?x=1');assert status==200 and headers['x-route']=='break' and body==b'static-file'
status,headers,body=request('/append/item?old=2');assert status==200 and body==b'/target/item|new=1&old=2'
status,headers,body=request('/discard/item?old=2');assert status==200 and body==b'/target/item|'
assert request('/loop/start')[0]==500
assert 'rewrite or internal redirection cycle' in docker('exec',container,'cat','/tmp/error.log').stdout
print('return 308 creates a client-visible redirect; the explicit follow-up retains POST and the query')
print('last restarts location selection; break keeps the location while changing the URI used by its static handler')
print('rewrite appends old query arguments unless discarded explicitly; an internal rewrite cycle is stopped with HTTP 500')
A POST to /old?x=1 receives status 308 and Location: /destination?x=1. The fixture disables absolute redirect generation so this local target remains relative and does not depend on an assigned host port. The response is inspected before any follow-up, establishing exactly what nginx told the client to do.
The script then explicitly sends a second POST to that Location and receives POST|/destination|x=1. This verifies the destination and query when following with the intended method. It does not claim to test every browser’s automatic redirect handling or cache behavior; the test client deliberately controls the follow-up rather than silently hiding it.
The request /last/item?x=1 rewrites to /target/item and starts location selection again. The target location returns its diagnostic URI and an X-Route value of target. By contrast, /break/item?x=1 changes the URI but remains in the original location, whose static handler serves /srv/target/item and marks the response as break.
These outcomes show that break does not stop all request processing. It stops the current rewrite sequence while allowing the configured content handling to continue. Likewise, last is not an external redirect: the client receives the final 200 response directly, without a Location hop to the internally selected target.
For /append/item?old=2, the replacement’s new=1 query is combined with the original arguments, producing new=1&old=2. The discard example ends its replacement with a question mark and produces no arguments. Both cases assert the final values, preventing a redirect or rewrite test from overlooking duplicate, leaked, or unexpectedly removed query data.
The loop route repeatedly rewrites into its own matching path family. nginx stops the cycle with HTTP 500 and writes an internal-redirection-cycle diagnostic to its error log. A successful syntax check therefore does not prove a request cannot loop; only exercising the matching paths establishes that the configured transitions terminate as intended.
Test the transition the client actually receives
Use a direct return with an explicit status for simple public redirects, and reserve rewrite patterns for mappings that require their matching behavior. Keep destination paths and query handling explicit. Avoid deriving unrestricted redirect destinations from untrusted input, and verify canonical-host policy where an absolute target depends on incoming host information.
For a public migration, test status, Location, method expectations, and the final target before choosing cache policy. A temporary status is not a substitute for explicit cache controls when reuse must be prevented. For internal mappings, test the final location, filesystem or upstream path, arguments, and error cases.
Include near-miss paths and already-normalized targets in regression checks. A rule that works once can still redirect its own destination or collide with a later location. Keep the evidence for both the original response and the eventual result so a routing change remains explainable after deployment.
Sources
- nginx rewrite module, return redirects, last versus break, query handling, and rewrite limits, checked 2026-09-12.
- RFC 9110 HTTP semantics, redirect status meanings and method-preservation requirements, checked 2026-09-12.
- RFC 9111 HTTP caching, cacheability depending on response semantics and cache controls, checked 2026-09-12.