
Nginx redirects for multiple domains stop being simple somewhere around the twentieth rule. A handful of one-off rewrite or return statements are easy to write and easy to read, right up until you’re managing hundreds of them across several domains, at which point every new redirect risks silently conflicting with one already in the file. This assumes you’re already comfortable with a standard Nginx and WordPress server setup – the pattern below builds on top of that, not in place of it.
Why Per-URL Rewrite Rules Stop Scaling
A dozen individual rewrite directives in a server block are manageable to review by eye. A few hundred, built up one redirect at a time across a multi-domain consolidation, are not. Nginx evaluates rewrite rules in the order they’re written, so a new rule added near the top can silently short-circuit rules further down the file, and nobody notices until testing turns up a redirect going to the wrong place.
The fix is structural: replace individual rewrite rules with a single lookup table, built once and reviewed as a whole, the same way you already reviewed the redirect map as a document before touching any config.
Debugging a wall of individual rewrite rules is also slow in a way that isn’t obvious until you’re doing it under pressure. Finding out why one specific URL redirects incorrectly means reading every rule above it in file order, checking each one for an unintended match. A lookup table replaces that search with a single question: what does this one URL resolve to in the map. That’s a difference that matters most exactly when you’re troubleshooting a live issue, not when everything is working.
The Map Block Pattern With a Safe, Deliberate Default
Nginx’s map directive takes a request variable and looks it up against a table of patterns, assigning the result to a new variable you define. Instead of hundreds of individual rules, you get one table and one return statement that uses it. Every entry in your redirect-map spreadsheet becomes one line in this table – a direct, reviewable translation from document to config.
The default entry in the map matters as much as any individual redirect. It’s what fires for every URL you haven’t explicitly mapped, so it should point somewhere genuinely useful, exactly like the fallback decision made earlier in the redirect-map planning stage.
The map block supports both literal paths and regular expressions as keys, matched top to bottom with the first match winning. Literal entries handle your one-to-one redirects; a regex entry prefixed with ~ handles an entire pattern, like every URL under a given path, in a single line instead of one entry per URL. That’s the same one-to-one-versus-pattern split from the planning stage, expressed directly in config syntax.
A Complete Example: Map Block, Default Fallback, and Admin Carve-Out
Here’s what that looks like as a working configuration, redirecting an old domain to a consolidated new one while keeping its own admin, login, and cron access untouched during the transition:
# Redirect map - one entry per row in your redirect-map spreadsheet
map $request_uri $redirect_uri {
default /downloads/product-x/;
/product-x/ /downloads/product-x/;
/blog/old-post-title/ /blog/new-post-title/;
~^/help/ /downloads/product-x/;
}
server {
listen 443 ssl;
server_name oldsite-a.example.com;
# Keep wp-admin working on the old domain during the transition
location ^~ /wp-admin/ {
try_files $uri $uri/ /index.php?$args;
}
# Keep login and cron reachable - don't let the catch-all below eat these
location = /wp-login.php {
try_files $uri =404;
}
location = /wp-cron.php {
try_files $uri =404;
}
# Everything else follows the redirect map
location / {
return 301 https://newsite.example.com$redirect_uri;
}
}
The order of the location blocks matters less than it looks like it should – Nginx picks the most specific match regardless of where a block sits in the file, so the exact-match /wp-login.php and /wp-cron.php blocks win over the prefix-matched /wp-admin/ block, which in turn wins over the catch-all /. That’s what keeps the admin, login, and cron paths reachable while every other request falls through to the redirect map.
Test the Config Syntax Before You Reload, Every Time
A syntax error in a large map block is easy to introduce and easy to catch, as long as you catch it before it reaches production. Nginx can validate a config file without applying it:
sudo nginx -t
Run this after every change, before every reload, without exception. A reload with a broken config can take down every domain sharing that Nginx instance, not just the one you were editing – a much larger blast radius than the single redirect you were trying to fix.
Handling True Cross-Domain Exceptions as Isolated, Documented Rules
A genuine exception, like a single path that needs to redirect to an entirely unrelated domain instead of your new consolidated site, doesn’t belong inside the shared map block. Give it its own explicit location, placed and commented so anyone reading the config later understands it’s deliberate:
# Deliberate exception - documented in the redirect map as a cross-domain redirect
location = /partner-integration/ {
return 301 https://unrelated-service.example.com/;
}
Keeping exceptions isolated and commented, rather than folded quietly into the main map, is what makes the config still auditable a year from now, once whoever wrote it has moved on to something else.
Build a Full Test List and Run It With curl, Not a Sample
Testing five or six redirects by hand in a browser feels like enough, but it only proves those five or six work. Every URL in your redirect-map spreadsheet needs to be tested, and the only practical way to do that at scale is a script that reads the same spreadsheet you already built and checks every row automatically:
#!/bin/bash
# test-redirects.sh
# Reads old_url,expected_new_url pairs from redirects.csv
# (exported directly from your redirect-map spreadsheet)
while IFS=, read -r old_url expected_url; do
result=$(curl -s -o /dev/null -w "%{http_code} %{redirect_url}" "$old_url")
status="${result%% *}"
location="${result#* }"
if [ "$status" != "301" ]; then
echo "FAIL: $old_url returned $status, expected 301"
elif [ "$location" != "$expected_url" ]; then
echo "FAIL: $old_url redirected to $location, expected $expected_url"
else
echo "OK: $old_url"
fi
done < redirects.csv
Run this against every domain before go-live, and again immediately after, so any difference between staging and production shows up while it’s still cheap to fix.
The Pitfall: Assuming Documented Location Priority Always Holds in Production
Nginx’s location-matching order is well documented, and it’s tempting to trust it completely once you’ve read the docs once. In practice, something in front of your server – a CDN, a reverse proxy, or a caching layer – can serve a cached response from before your config change, making it look like Nginx is ignoring its own documented priority when the real cause is a cached copy that hasn’t expired yet.
Test the live behavior directly, from outside any cache, rather than assuming the documentation describes what a visitor is actually seeing right now. A request with cache-busting headers, or a quick check with the cache disabled at the edge, tells you what’s really happening far faster than re-reading the Nginx docs a second time.
Your Nginx Redirect Testing Checklist
Before pointing DNS at the new environment, confirm:
- Every redirect-map row exists as an entry in the
mapblock, not a scattered individual rule - The map’s default fallback points to a genuinely useful destination, not a blanket 404
/wp-admin/, login, and cron access are confirmed working on the old domain during the transition- Every true cross-domain exception is isolated, commented, and documented in the redirect map
- Every mapped URL has been tested with a script, not a manual sample, and tested again from outside any cache
With redirects tested and confirmed, the next technical task is reorganizing the media libraries feeding into the new site, so shared assets from multiple sources don’t collide the moment they land in one media library.

