.htaccess
Redirects, rewrites, headers and caching - plus the handful of traps that produce a rule which looks right, tests fine, and quietly does the wrong thing in production.
What this page covers
Redirects
Redirect, RedirectMatch and RewriteRule, and which to reach for.
The traps
Five that produce working-looking rules with the wrong behavior. All met in production.
Rewrites and conditions
RewriteCond, the flags worth knowing, and guarding against your own output.
Headers and caching
Cache-Control by path and by type, and the MIME types a host may not know.
Access and errors
Blocking paths, custom error documents, and forcing HTTPS.
Testing it
How to check a rule without deploying it to find out.
Redirects, simplest first
# Exact path, and everything under it. No regex.
Redirect 301 /old-page/ /new-page/
# Regex, anchored and escaped. The dot is escaped so it means a dot.
RedirectMatch 301 "^/old/page\.html$" /new/page/
# Optional trailing slash, which is what you almost always want
RedirectMatch 301 "^/old-page/?$" /new-page/
# To another domain - absolute, or it rewrites within this one
RedirectMatch 301 "^/moved/?$" https://example.com/moved/
Use Redirect for a prefix, RedirectMatch for a pattern, and
RewriteRule only when you need a condition. Reaching for mod_rewrite
first is how a simple redirect becomes a debugging session.
The five traps
An unescaped dot matches any character. RedirectMatch 301 "^/pricing.html$" also matches /pricingXhtml. Escape it: \.
A pattern with no anchor matches every URL that starts the same way.
^/blog catches /blogging-tips. End it: ^/blog/?$
[L] does not mean "last" in a per-directory context. An internal
rewrite re-runs the entire ruleset from the top, so a rule can match its
own output. Guard against it:
RewriteCond %{REQUEST_URI} !^/_site/
RewriteCond %{DOCUMENT_ROOT}/_site%{REQUEST_URI} -f
RewriteRule ^(.*)$ /_site/$1 [L]
$N inside a RewriteCond refers to the PREVIOUS rule, not the one
below it. Use %{REQUEST_URI} when you mean the current request.
Rewriting straight to 404.html serves it with status 200. That is a soft 404: crawlers are told the missing page exists. Emit a real status and let ErrorDocument render the page:
RewriteRule ^ - [R=404,L]
ErrorDocument 404 /404.html
Conditions and flags
RewriteEngine On
RewriteBase /
# Only if the file does not already exist
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ /index.html [L]
# Only for one host
RewriteCond %{HTTP_HOST} ^www\.example\.com$ [NC]
RewriteRule ^(.*)$ https://example.com/$1 [R=301,L]
# Block a path outright
RewriteRule ^\.git(/|$) - [F,L]
# Gone, rather than moved - tells a crawler to stop asking
RewriteRule ^retired-section(/|$) - [G,L]
Flags worth knowing: [L] stop this pass, [R=301] external permanent
redirect, [F] forbidden, [G] gone, [NC] case-insensitive,
[OR] combine the previous condition with the next, [QSA] keep the
query string.
Headers, caching and MIME types
<IfModule mod_headers.c>
# Content-hashed assets never change - cache them hard
<FilesMatch "\.(js|css|woff2|webp|avif)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
# HTML must revalidate, or a deploy takes ten minutes to appear
<FilesMatch "\.html$">
Header set Cache-Control "public, max-age=0, must-revalidate"
</FilesMatch>
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
</IfModule>
# Some hosts have no mapping for newer image formats and serve them with
# NO Content-Type at all: a 200, the right bytes, and nothing saying what
# the file is. Everything looks fine because browsers sniff.
<IfModule mod_mime.c>
AddType image/webp .webp
AddType image/avif .avif
</IfModule>
Matching cache rules by PATH rather than extension is usually better:
an extension list silently misses the next format you adopt, while
SetEnvIf Request_URI "/assets/" covers whatever lands there.
Testing a rule before you trust it
# What status and where does it go?
curl -sSI https://example.com/old-page/ | grep -iE 'HTTP/|location'
# Follow the whole chain - a redirect to a redirect loses authority twice
curl -sSLo /dev/null -w '%{num_redirects} hops -> %{url_effective}\n' \
https://example.com/old-page/
# Did the syntax break the file? Run before reloading.
apachectl configtest
Check the chain, not just the first hop. Moving a page twice is how
old -> older-new -> newest happens, and each hop costs something. Point
the original at the final destination instead.
An .htaccess written on Windows and uploaded to Linux can carry carriage returns, which produce directives that look right and behave subtly wrong. Save it with LF endings.