Redirect Fixes in Apache and nginx
If your site runs on Apache or nginx, most redirects end up as a line or two of configuration. Those lines are short but unforgiving: a missing anchor makes a rule match far more than you meant, a missing flag lets rules run into each other, and a pattern that also matches its own target sends visitors round in circles.
This guide is for site owners and developers who edit .htaccess or server
configuration directly. It explains which directive to reach for, how each one treats paths and query
strings, how to handle long lists of old URLs, and how to test changes safely.
- Which directive for which job
- Apache: mod_alias, mod_rewrite and .htaccess
- nginx: return, rewrite and server blocks
- Regular expressions that do not overreach
- Query strings: kept, dropped or matched
- Long redirect lists without long config files
- The fix, step by step
- When a rule does not do what you expect
- Questions and answers
Which directive for which job
Both servers offer a simple directive and a powerful one. The simple one is easier to read, harder to get wrong and cheaper to run, so use it unless you need what only the powerful one offers.
| Job | Apache | nginx |
|---|---|---|
| One old path to one new path | Redirect 301 /old /new (mod_alias) | location = /old { return 301 /new; } |
| A pattern of paths, such as a whole folder | RedirectMatch 301 ^/old/(.*)$ /new/$1 | location ^~ /old/ { rewrite ^/old/(.*)$ /new/$1 permanent; } |
| Depends on host, scheme, header or query string | RewriteCond plus RewriteRule
(mod_rewrite) | A separate server block, or map plus return |
| Whole host to another host | A dedicated VirtualHost with Redirect 301 / https://www.example.co.uk/ | A
dedicated server block with return 301 https://www.example.co.uk$request_uri; |
| Hundreds of one-to-one moves | RewriteMap (main configuration only) | map
in the http block |
Two behaviours trip people up. Apache's Redirect is a prefix match: Redirect 301 /shop /store
also sends /shop/boots to /store/boots and /shopping to /storeping.
If you mean one exact page, use RedirectMatch 301 ^/shop$ /store. And nginx's return
stops processing immediately, while rewrite changes the URI and may continue through other
rules unless you use permanent, redirect, last or break.
server
block, not inside an if ($host = ...) test. Separate blocks are clearer, faster and avoid
the well-known surprises of if inside location.Apache: mod_alias, mod_rewrite and .htaccess
On Apache, rules can live in the main server configuration, a virtual host, or an .htaccess
file in a folder. .htaccess is read on every request and only works if the virtual host
allows it with AllowOverride; if nothing you write there has any effect, check that first.
On shared hosting, .htaccess is usually the only place you can edit.
Things that behave differently in .htaccess
- No leading slash in the pattern. In
.htaccess, the path aRewriteRulesees has the folder prefix removed, so it isold-page, not/old-page. A rule written as^/old-page$works in a virtual host and silently never matches in.htaccess.RedirectandRedirectMatchalways use the full path with its leading slash. - [L] is not the end. In a folder context,
[L]ends the current pass, but if the URL was rewritten internally Apache starts the rules again from the top. For an external redirect withR=301this rarely matters, but for internal rewrites that are meant to stop,[END]does what people expect[L]to do. - Mixing modules. mod_alias and mod_rewrite run independently, and in
.htaccessmod_rewrite rules generally take effect first regardless of the order in the file. If aRedirectline appears to be ignored, aRewriteRuleabove or below it may already have handled the request. Use one module per file where you can, and mod_rewrite when you need conditions.
A clean mod_rewrite block
RewriteEngine On
# 1. host and scheme in one hop (skip if the CDN or panel already does this)
RewriteCond %{HTTPS} off [OR]
RewriteCond %{HTTP_HOST} !^www\.example\.co\.uk$ [NC]
RewriteRule ^ https://www.example.co.uk%{REQUEST_URI} [R=301,L]
# 2. single moved pages
RewriteRule ^services/plumbing-repairs$ /services/plumbing/ [R=301,L]
RewriteRule ^team\.html$ /about/team/ [R=301,L]
# 3. whole folder moved, keeping the rest of the path
RewriteRule ^news/(.+)$ /blog/$1 [R=301,L]
# 4. application front controller last
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [END]
Order matters: host rules first, then specific pages, then patterns, then the catch-all that hands everything else to your application. If the catch-all comes first, none of the redirects below it ever run.
%{HTTPS} off is always true, which causes a loop. Test
%{HTTP:X-Forwarded-Proto} instead, and only if the proxy sets it reliably.nginx: return, rewrite and server blocks
nginx has no per-folder files. Every rule lives in the configuration, which is checked and loaded when you reload the service. That makes rules faster and more predictable, but it means you need server access, or a hosting panel box that writes into the configuration for you.
# every http request, any host, goes straight to the final https host
server {
listen 80;
server_name example.co.uk www.example.co.uk;
return 301 https://www.example.co.uk$request_uri;
}
# https on the bare domain
server {
listen 443 ssl;
server_name example.co.uk;
# ssl_certificate lines here
return 301 https://www.example.co.uk$request_uri;
}
server {
listen 443 ssl;
server_name www.example.co.uk;
location = /services/plumbing-repairs { return 301 /services/plumbing/; }
location = /team.html { return 301 /about/team/; }
location ^~ /news/ {
rewrite ^/news/(.+)$ /blog/$1 permanent;
}
location / {
try_files $uri $uri/ /index.php?$args;
}
}
How nginx chooses a location matters for redirects. Exact matches with =
win outright. Otherwise nginx finds the longest matching prefix; if that prefix is marked ^~
it is used, and if not nginx tries regular expression locations in the order they appear. A redirect
placed in a regex location can therefore be skipped because another regex location earlier in the file
matched first. Exact and ^~ locations avoid the guesswork.
Use $request_uri when copying the whole original path and query string, because it is
the address exactly as the visitor sent it. $uri is the decoded, normalised path without
the query string, and can change after internal rewrites.
Regular expressions that do not overreach
Most broken redirects are pattern mistakes. These are the ones the Redirect Checker exposes most often:
| Pattern | What goes wrong | Safer version |
|---|---|---|
old-page | No anchors, so it also matches my-old-page-archive. | ^old-page$ |
^products/(.*) to /shop/$1 | Also matches products/
alone, redirecting the listing page to an empty /shop/ you may not want. | ^products/(.+)$ |
^blog/(.*)$ to /blog/$1/ | The target matches the pattern, so
/blog/post/ redirects to /blog/post//, and so on: a loop. | ^blog/(.*[^/])$,
or add a condition that the path does not already end in a slash. |
^page.html$ | The unescaped dot matches any character, so pagexhtml
matches too. | ^page\.html$ |
^About-Us$ | Case-sensitive; /about-us does not match. | Add
[NC] on Apache or use ~* in nginx, if the old URLs really varied in case. |
The loop test is simple and worth doing for every pattern rule: write down the target for one real example, then ask whether that target would match the same pattern. If it would, the rule needs a tighter pattern or a condition that excludes the target.
Query strings: kept, dropped or matched
The two servers handle the part after ? differently, and the difference decides whether
campaign tags survive a redirect.
- Apache RewriteRule keeps the original query string by default. If the target has
its own
?, the original is replaced unless you add[QSA].[QSD]drops it. A pattern inRewriteRulenever sees the query string; to match on it, useRewriteCond %{QUERY_STRING}. - Apache Redirect and RedirectMatch pass the query string through to the target.
- nginx rewrite appends the original arguments unless the replacement ends in
?. - nginx return sends exactly the address you give it, so add
$is_args$argsor use$request_uriif you want the query string kept.
# Apache: old product IDs in the query string
RewriteCond %{QUERY_STRING} (^|&)id=42(&|$)
RewriteRule ^product\.php$ /shop/oak-bench/? [R=301,L]
# nginx: same move
location = /product.php {
if ($arg_id = "42") { return 301 /shop/oak-bench/; }
}
The trailing ? on the Apache target drops id=42 so it is not carried onto the new, clean address.
Long redirect lists without long config files
After a site rebuild you may have hundreds of old URLs. Hundreds of separate rules are slow to read
and easy to break, and in .htaccess they are evaluated on every request. Both servers have
a lookup table for this.
# nginx: in the http block
map $request_uri $moved_to {
default "";
/services/plumbing-repairs /services/plumbing/;
/team.html /about/team/;
/news/opening-hours-2024 /blog/opening-hours/;
}
# in the server block
if ($moved_to) { return 301 $moved_to; }
# Apache: in the virtual host, not .htaccess
RewriteMap moved "txt:/etc/apache2/redirects/moved.txt"
RewriteCond ${moved:%{REQUEST_URI}|NONE} !=NONE
RewriteRule ^ ${moved:%{REQUEST_URI}} [R=301,L]
# moved.txt, one pair per line
/services/plumbing-repairs /services/plumbing/
/team.html /about/team/
Keep the list in a file under version control or in a spreadsheet you export, and before loading it, check that no target also appears as a source. That single check prevents most chains.
map above matches $request_uri, which includes
the query string. If old links often carry tracking tags, match on $uri instead so /team.html?utm_source=x
still finds its entry.The fix, step by step
Step 1
Find every rule that already redirects
Search
the server for redirect rules before adding any. On Apache, look in the virtual host files and every
.htaccess from the web root downwards for Redirect, RedirectMatch
and RewriteRule lines with an R flag. On nginx, run nginx -T and
search the output for return 30 and rewrite. Note which file each rule is in
and whether a CDN, panel or CMS also adds redirects.
Step 2
Pick the simplest directive for each job
Use an exact location with return 301 in nginx, or RedirectMatch
with anchors in Apache, for single pages. Reserve RewriteRule and rewrite for
rules that genuinely need conditions or captured parts of the old path. Put whole-host redirects in their
own virtual host or server block. Replacing clever rules with plain ones makes the file
shorter and removes most accidental matches before they ever reach a visitor.
Step 3
Anchor and escape every pattern
Start
each pattern with ^ and end it with $, escape literal dots as \.,
and use (.+) rather than (.*) when the captured part must not be empty. In
.htaccess, leave out the leading slash. For each rule, take one real example, work out its
target, and confirm the target does not match the same pattern, which is how redirect loops are born.
Step 4
Decide what happens to the query string
For each rule, decide whether tracking tags, search terms and other parameters should follow
the visitor to the new address. Apache RewriteRule keeps them unless the target ends in
? or you add [QSD]; nginx return drops them unless you add $is_args$args.
Where old URLs identified pages by a parameter, match it with RewriteCond %{QUERY_STRING}
or $arg_name and drop it from the target, so the new page has a clean address.
Step 5
Move long URL lists into a map
If
you have more than a few dozen one-to-one redirects, move them into an nginx map or an Apache
RewriteMap text file in the main configuration. Before loading the list, sort it and check
that no destination appears anywhere in the source column, and that every destination returns 200. Keep
the file under version control so later additions are reviewed rather than pasted in at the bottom.
Step 6
Test the syntax, then trace each rule
Run apachectl configtest or nginx -t before every reload so a typo
cannot take the site down. Then request each changed URL with curl -sI and follow the chain
with the Redirect Checker from the http and non-www starting points as well. Every old address should
reach a 200 page in one hop, with the query string handled the way you decided.
When a rule does not do what you expect
Both servers can tell you exactly what their rewrite engine did, which is quicker than guessing.
- Apache: in the virtual host, set
LogLevel alert rewrite:trace3, reload, make one request and read the error log. Each line shows the pattern tried, the input and whether it matched. Turn it off afterwards; the log grows quickly. - nginx: add
rewrite_log on;and set theerror_loglevel tonoticefor the server block.returndoes not appear in this log, butrewritedoes. - Browser caching: a 301 from an earlier mistake can be cached by the browser for
a long time. Test in a private window or with
curl, never only in your everyday browser. - Not your rule at all: if the response headers show a CDN or the
Serverheader is not your web server, the redirect is coming from further up the stack.
curl -sI http://example.co.uk/news/spring-opening | grep -iE "^(HTTP|location|server)"
1. Should I use Redirect or RewriteRule in .htaccess?
Use Redirect or RedirectMatch for straightforward page and folder moves, because they are easier to read and have fewer surprises. Remember that Redirect is a prefix match, so it catches everything that starts with the old path; RedirectMatch with anchors is more precise. Use RewriteRule when the redirect depends on something other than the path, such as the host, the scheme, a header or the query string. Avoid mixing both modules for the same URLs in one file.
2. Why does my RewriteRule work in the virtual host but not in .htaccess?
In .htaccess the path that RewriteRule sees has the folder prefix removed, including the leading slash. A pattern written as ^/old-page$ matches in a virtual host but never matches in .htaccess, where it needs to be ^old-page$. Also check that the virtual host has AllowOverride set to allow rewrite rules for that folder, and that RewriteEngine On appears in the file. Without a suitable AllowOverride setting, Apache ignores the .htaccess file completely and nothing in it runs.
3. Is nginx return 301 better than rewrite permanent?
For a fixed destination, yes. return sends the redirect immediately without evaluating a regular expression, and it cannot accidentally continue into other rules. rewrite with permanent is useful when you need to capture part of the path and reuse it in the target, such as moving a whole folder. Both send a 301. A common pattern is an exact or prefix location for the old path containing a single return or rewrite.
4. Does a redirect keep the query string?
It depends on the directive. Apache RewriteRule keeps the original query string unless the target ends with a question mark or you add the QSD flag, and it replaces it if the target has its own query string unless you add QSA. nginx rewrite appends the original arguments unless the replacement ends with a question mark, while nginx return sends only what you write, so add is_args and args or use request_uri to keep them.
5. How many redirects can I put in .htaccess before it slows the site?
There is no fixed number, but every rule in .htaccess is read and tested on every request to that folder, including requests for images and scripts. A few dozen rules are rarely noticeable. Hundreds or thousands start to add measurable work. For long lists, move them into the main server configuration using RewriteMap on Apache or a map block on nginx, where the lookup is done once from a table instead of rule by rule.
6. How do I redirect a whole old domain to a new one on Apache or nginx?
Give the old domain its own virtual host or server block that does nothing but redirect. On Apache, a VirtualHost for the old names with Redirect 301 / https://www.example.co.uk/ sends every path to the same path on the new domain. On nginx, a server block with return 301 https://www.example.co.uk$request_uri does the same. Where old pages have new addresses, add those specific rules on the new site rather than chaining through the old one.
7. Why do I get a redirect loop after adding an HTTPS rule?
Usually because the server cannot see that the visitor is already on HTTPS. When a CDN, load balancer or proxy handles the secure connection and talks to your server over plain HTTP, a rule that checks whether HTTPS is off always fires, and the visitor is sent back to the same address. Check the forwarded protocol header from the proxy instead, or let the proxy handle the HTTPS redirect and remove the server rule.
The Redirect Checker follows each URL through its full chain, recording every status code, Location header and hop time, and flags long chains, loops and 302s that should be 301s.
Open Redirect Fixes โ