Nginx can route requests with regex, and the heavy lifting happens in three places: location blocks can match with regex, the rewrite directive takes a regex, and server_name accepts regex. (Plenty of requests are handled by exact or prefix location matches with no regex at all, which is by design and fast.) If you came from Apache and your first instinct is to drop a .htaccess file in a directory, stop: Nginx has no .htaccess. All configuration lives in the server config, and a change is not live until you reload Nginx.
This article covers how Nginx regex actually works, the location matching order that decides which block wins, why return beats rewrite for most redirects, and then a copy-paste config for every common job: HTTPS, www, trailing slashes, 301 redirects, clean URLs, blocking bad traffic.
The big difference from Apache: no .htaccess
In Apache, a .htaccess file lets you change rewrite rules per directory, and the change takes effect on the next request. Nginx has nothing equivalent. Every directive lives in the main configuration (typically /etc/nginx/nginx.conf and the files it includes from /etc/nginx/conf.d/ or /etc/nginx/sites-enabled/).
That means two things. First, you need write access to the server config, not just a web directory. Second, every change follows the same loop:
sudo nginx -t # test the config for syntax errors
sudo systemctl reload nginx # apply it with zero dropped connectionsNever skip nginx -t. A syntax error on reload is caught and the old config keeps running; a syntax error you did not test before a restart takes the site down. The lack of .htaccess is not a limitation, it is the reason Nginx is fast: there is no per-directory file to stat and parse on every request.
location blocks and the matching priority
A location block decides which configuration applies to a request path. The modifier in front of the path controls how it matches:
location = /pathis an exact match. Nothing else is checked if it hits.location /pathis a prefix match (no modifier). Matches anything starting with/path.location ^~ /pathis a prefix match that wins outright: if it is the longest prefix match, Nginx stops and does not check regex locations.location ~ patternis a case-sensitive regex match.location ~* patternis a case-insensitive regex match.
The priority order is the part that trips people up. Nginx does not simply read top to bottom. It selects a location like this:
- If an exact (
=) match exists, use it and stop. - Find the longest matching prefix location and remember it.
- If that longest prefix location has the
^~modifier, use it and stop. - Otherwise check regex locations in the order they appear in the file, and use the first one that matches.
- If no regex matches, fall back to the longest prefix location from step 2.
location = / {
# exact match for "/" only
}
location / {
# prefix fallback for everything
}
location ^~ /images/ {
# everything under /images/ ... regex blocks below are skipped for these
}
location ~* \.(gif|jpg|jpeg|png|webp)$ {
# case-insensitive regex: image extensions anywhere else
}The two rules to remember: among regex locations, first match wins (order matters, unlike prefix matches where longest wins), and ^~ on a prefix location switches the regex pass off entirely for paths under it.
How the rewrite directive and its regex work
rewrite rewrites the request URI when a regex matches:
rewrite regex replacement [flag];The regex is matched against the URI. If it matches, the URI becomes the replacement string. Capture groups in the regex become $1 through $9 in the replacement; named captures (?<name>...) become $name. Nginx regex is PCRE, the same engine described in the regex cheat sheet.
The flag is where the behavior is decided:
laststops the currentrewritedirectives and restarts the location search with the new URI. Use it when the rewritten URI should be re-evaluated against yourlocationblocks.breakstops the currentrewritedirectives and keeps processing in the current location. Use it when the rewrite is final and should not re-trigger location matching.redirectreturns an external 302 (temporary) redirect to the client.permanentreturns an external 301 (permanent) redirect to the client.
One automatic behavior: if the replacement string starts with http://, https://, or $scheme, Nginx returns a redirect to the client regardless of the flag. And the original query string is appended to the result unless you end the replacement with a ?.
# Internal rewrite, re-evaluated against locations
rewrite ^/products/(.*)$ /shop/$1 last;
# External 301 redirect the browser sees
rewrite ^/old-section/(.*)$ /new-section/$1 permanent;return beats rewrite for redirects
For a plain redirect, do not use rewrite. Use return. It is faster, clearer, and the official Nginx guidance.
return code [text];
return code URL;
return URL;return 301 https://example.com/new sends a clean permanent redirect and stops processing. return 302 ... does the temporary version. return 444 is a special Nginx code that closes the connection with no response at all, which is the most efficient way to drop unwanted traffic.
One caveat on the one-argument return URL; form: it is specifically a temporary (302) redirect, and the URL must start with http://, https://, or $scheme. For a local redirect always use the two-argument form with an explicit code, return 301 /path; or return 302 /path;, so the status code is unambiguous.
The rule of thumb: if you are sending the browser somewhere, use return. Reach for rewrite only when you need to internally change the URI and keep serving the request (capturing and reshaping a path before it hits a location), or when the redirect target depends on a regex capture.
Force HTTPS
Problem: requests on http:// are insecure and split your SEO signals across two protocols.
Solution: a dedicated server block on port 80 whose only job is to redirect to HTTPS.
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}$host carries the requested hostname and $request_uri carries the full original path and query string, so the visitor lands on the exact same page over HTTPS. This is a return, not a rewrite, and not an if: a separate listening block is the cleanest and fastest pattern.
Redirect www to non-www (or the reverse)
Problem: www.example.com and example.com both resolve, splitting ranking signals.
Solution, www to non-www: give the www host its own server block that does nothing but redirect.
server {
listen 443 ssl;
server_name www.example.com;
# ssl_certificate / ssl_certificate_key go here
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl;
server_name example.com;
# the real site config
}Solution, non-www to www is the mirror image: the bare-domain server block redirects to the www host, and the www block serves the site. A dedicated server block per hostname is the Nginx-idiomatic approach. It avoids if, and Nginx picks the right block by server_name before any rewrite logic runs.
Add or remove the trailing slash
Problem: /about and /about/ serve the same content at two URLs.
Nginx adds a trailing slash to real directories automatically. For everything else you decide explicitly.
Solution, remove a trailing slash from application routes (URLs your framework owns, not real filesystem directories):
location ~ ^/(.+)/$ {
return 301 /$1;
}The regex captures the path without its trailing slash into $1 and return sends the browser to the slashless form. Important caveat: this regex has no directory check, so it will also match a request for a real directory like /docs/ and fight Nginx's normal behavior of adding a slash to real directories. Use it only on a site where URLs are application routes, or scope it to a specific path prefix. It is not a safe site-wide rule for a server that also serves real directories from disk.
Solution, add a trailing slash to a specific section:
location = /about {
return 301 /about/;
}For application routes where the framework, not the filesystem, owns the URL, normalize the slash in one place rather than scattering rules.
Redirect an old URL to a new one
Problem: you renamed or moved a page and the old URL is indexed and linked.
Solution, a single page is an exact-match location with a return:
location = /old-page {
return 301 /new-page;
}Solution, a whole section where the path structure is preserved uses a regex rewrite with permanent:
location /blog/ {
rewrite ^/blog/(.*)$ /articles/$1 permanent;
}(.*) captures the remainder of the path and $1 rebuilds it under the new prefix. The permanent flag makes it a 301.
Remove the file extension from URLs
Problem: your URLs expose .html or .php, which looks dated.
Solution: serve the extensionless URL internally with try_files, and redirect anyone who requests the extension directly.
# Redirect /page.html to the clean /page
location ~ ^/(.*)\.html$ {
return 301 /$1;
}
# Serve /page by trying /page.html on disk
location / {
try_files $uri $uri.html $uri/ =404;
}try_files checks each candidate in order and serves the first one that exists. $uri then $uri.html means a request for /about serves /about.html from disk without the visitor ever seeing the extension. The =404 at the end returns a clean 404 when nothing matches.
Front-controller routing with try_files
Problem: a PHP application (WordPress, Laravel, most frameworks) needs every request that is not a real file to go to index.php so the application can route it.
Solution: this is what try_files was built for. No rewrite, no if.
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass unix:/run/php/php-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}The first block tries the literal file, then a directory, then falls back to index.php with the original query string preserved. The second block hands .php requests to PHP-FPM, with try_files $uri =404 so requests for non-existent .php files do not get passed to the interpreter. This is the standard try_files pattern recommended for WordPress, Laravel, and most PHP applications on Nginx.
Redirect an entire domain
Problem: you are migrating to a new domain and every URL must follow.
Solution: a server block for the old domain that redirects everything.
server {
listen 80;
listen 443 ssl;
server_name example.com www.example.com;
# ssl_certificate / ssl_certificate_key for the OLD domain still required
return 301 https://newsite.com$request_uri;
}$request_uri preserves the full path and query string. Keep the old domain's TLS certificate valid for as long as the redirect runs, or HTTPS visitors hit a certificate error before the redirect can fire. After the move, confirm the new domain resolves and is configured correctly: I run every migrated domain through the DNS Inspector at dnschkr.com, and the wider process is in my DNS health check walkthrough.
Block by user-agent, referer, or IP
Problem: a scraper, bad bot, or spam referer is hammering the site.
Solution, block a user-agent. This is one of the few places if is the right tool, because if with return is on the documented safe list:
if ($http_user_agent ~* (badbot|scraperthing|evilcrawler)) {
return 403;
}The ~* makes the match case-insensitive. Keep the alternation list specific: matching bot alone would block Googlebot. Use return 444 instead of 403 to drop the connection with no response at all, which costs the attacker more and you less.
Solution, block hotlinking uses the purpose-built valid_referers directive rather than raw regex:
location ~* \.(jpg|jpeg|png|gif|webp)$ {
valid_referers none blocked example.com *.example.com;
if ($invalid_referer) {
return 403;
}
}valid_referers sets $invalid_referer to a non-empty value when the referer is not in the allowed set. none allows direct requests, blocked allows referers stripped by a firewall or proxy.
Solution, block an IP needs no regex at all. The ngx_http_access_module handles it:
deny 203.0.113.45;
allow all;Why "if is evil", and what to use instead
Nginx has an official wiki page titled "If is Evil". It is not a joke. Inside a location block, if is reliable for exactly two directives: return ... and rewrite ... last. Almost anything else inside an if (a try_files, a proxy_pass, setting most directives) can behave in ways that do not match what you expect, because if creates an implicit nested configuration context.
Practical rules:
- For redirects, use a dedicated
serverblock orreturn, notif. - For "serve this file or fall back", use
try_files, notif (-f ...). - For choosing a value based on a condition, use a
mapblock. - It is fine to use
ifforreturn 403/return 301on a user-agent or referer check, as in the blocking examples above. That is the safe subset.
A map block is the clean way to derive a variable from a regex without if:
map $http_user_agent $is_bot {
default 0;
~*(badbot|scraper) 1;
}
server {
location / {
if ($is_bot) { return 403; }
}
}map is evaluated once, lazily, and is far more predictable than a chain of if blocks.
Quirks and gotchas
The things that cost an afternoon.
Changes are not live until you reload. Edit the config, run sudo nginx -t, then sudo systemctl reload nginx. Forgetting the reload is the number-one "why isn't my change working" cause.
location order matters for regex, not for prefixes. Among regex locations the first match wins, so put more specific regex locations above general ones. Prefix locations are chosen by longest match regardless of order.
^~ turns off the regex pass. If a prefix location with ^~ is the longest match, your regex locations are never consulted for those paths. This is a feature (it makes /static/ fast) but a surprise if you forgot the ^~ is there.
rewrite ... last vs break. last restarts location matching with the new URI. break stops rewriting but stays in the current location. Using last inside a location that the rewritten URI also matches is a classic infinite-loop or unexpected-routing bug.
server_name regex needs a leading ~. A plain server_name example.com is a literal. A regex server name is server_name ~^www\d+\.example\.com$;. Escape literal dots as \. just as in any regex.
return ends request processing. When return fires it sends the response and stops the rewrite-module directives in that block. Nginx config is not procedural line-by-line, so other configuration directives in the block (an add_header, for example) can still shape the response. The practical point: do not expect a rewrite or another return placed after a return to ever run.
Test the regex in isolation. Paste the pattern into regex101.com with the PCRE flavor selected before wiring it into a location or rewrite.
Test and debug safely
A bad Nginx config can take the site down on restart. Work deliberately.
- Always
nginx -tfirst. It validates the full config.sudo nginx -treports the exact file and line of any error. - Reload, do not restart.
sudo systemctl reload nginx(orsudo nginx -s reload) applies the new config gracefully: in-flight requests finish, and if the new config is broken the old one keeps serving. Arestartdrops connections and, with a broken config, fails to come back up. - Watch the error log.
sudo tail -f /var/log/nginx/error.logshows what Nginx is doing. For rewrite-specific tracing, addrewrite_log on;and seterror_log /var/log/nginx/error.log notice;to log every rewrite step. - Check which location matched. Add
add_header X-Debug-Location "name-of-block" always;temporarily to alocationand inspect the response headers to confirm which block handled the request. - Keep a backup. Copy the working config before editing. Restoring a known-good file beats debugging a down site under pressure.
FAQ
See also
- How to Use Regex in .htaccess: the Apache
mod_rewritecounterpart to this article, forRewriteRuleandRewriteCond - How to Match a URL with Regex: the URL structure your
locationandrewritepatterns match against - Regex Cheat Sheet: the full PCRE syntax reference for the patterns here
- Regex Anchors: why
^and$are critical inlocationandrewriteregex - Regex Capturing Groups and Backreferences: how
$1and named captures work in rewrite replacements - How to Run a DNS Health Check on Your Domain: verifying a domain after a migration or redirect change
External references: the Nginx rewrite module documentation and the Nginx core module documentation are the authoritative sources for rewrite, return, location, and try_files. The official "If is Evil" page explains the if pitfalls in detail. Test patterns at regex101.com with the PCRE flavor selected.





