Order is the whole feature
The generated file always sequences the rewrite block the same way:
- Scheme and host canonicalization
- Custom redirects
- Trailing-slash policy
The reasoning is mechanical. A request for http://www.example.com/old-page that hits your custom redirect first gets sent to http://www.example.com/new-page, which is still on the wrong scheme and the wrong host, so it is canonicalized on the next pass. That is 2 hops instead of one, and every hop bleeds a little link equity and adds a round trip. Reverse 2 rules that both rewrite the host and you get a redirect loop that only shows up on one hostname, which is a miserable afternoon.
Trailing-slash normalization goes last so it tidies whatever the rules above produced rather than the other way round. Custom redirect patterns are written to tolerate an optional trailing slash, so they keep matching once that policy is switched on.
One hop instead of 2
The domain field is optional and it changes the output materially. Leave it blank and HTTPS and host canonicalization are 2 independent rules, so http://www.example.com costs 2 redirects. Fill it in and both collapse into one:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTPS} off [OR]
RewriteCond %{HTTP_HOST} !^example\.com$ [NC]
RewriteRule ^ https://example.com%{REQUEST_URI} [R=301,L]
</IfModule>
One rule, one 301 to the final URL, whichever of the four wrong variants the request arrived on.
One caveat the generator prints as a comment: behind a proxy or CDN, %{HTTPS} reads off on every request because TLS terminated at the edge. Swap that first condition for RewriteCond %{HTTP:X-Forwarded-Proto} !https or the rule redirects forever.
Canonical host and trailing slashes in htaccess rules
The canonical host field takes 3 values: leave alone, force www, force non-www. Which one you pick has no ranking effect. Serving both does, because www.example.com/pricing and example.com/pricing are 2 URLs to a crawler, splitting whatever signals the page has earned.
Trailing slash is the same argument one level down. /pricing and /pricing/ are distinct URLs. Either policy ships with the guard that keeps it safe:
- Add skips anything that looks like a real file, so
/robots.txtis never rewritten to/robots.txt/. - Remove skips real directories, because mod_dir issues its own
DirectorySlashredirect adding the slash back, and the 2 rules will ping-pong until the browser gives up.
Writing redirect lines with a rule generator
The redirects field takes one rule per line, from | to | status:
/old-pricing | /pricing | 301
/2019/legacy-post | /blog/legacy-post
/discontinued-item | /shop | 410
Status is optional and defaults to 301. Accepted values are 301, 302, 303, 307, 308 and 410. Anything else falls back to 301 with a warning, because Apache will not emit an arbitrary code from a RewriteRule here.
Use 301 for a move that is permanent, which is nearly always. 302 and 307 tell search engines to keep the old URL indexed, which is right for a seasonal campaign page and wrong for a site migration. 410 is not a redirect at all: it emits [G,L] and tells crawlers the URL is intentionally gone, which gets it dropped faster than a 404.
The audit catches 4 things before they reach the server:
- Self-redirects. A rule pointing at its own source loops until the browser stops it. Skipped.
- Duplicate sources. If
/oldis the source of 3 rules, only the first ever fires. The other 2 are dead lines someone will one day "fix" by editing the wrong one. - Chains.
/a → /bwhere/b → /ccosts 2 hops. Point the first rule at/c. - Redirecting
/. Sending the site root somewhere catches the destination too. That belongs at the vhost or DNS level, not here.
Destinations containing a ? get the QSA flag so the incoming query string survives. Worth knowing: RewriteRule matches the path only, never the query string. To redirect based on a parameter you need a RewriteCond %{QUERY_STRING} in front of it.
Compression, caching, and headers worth setting
Gzip wraps mod_deflate and lists the text types worth compressing: HTML, CSS, JavaScript, JSON, XML, SVG and fonts. Images, video and PDFs are deliberately absent. They are already compressed, so deflating them burns CPU to save nothing.
Browser caching wraps mod_expires with long lifetimes for static assets and zero for HTML. That asymmetry is the point. Cache a fingerprinted CSS file for a year and it never gets refetched; cache HTML for a year and you cannot ship a fix.
Security headers is a checkbox group with 3 entries, all uncontroversial:
X-Content-Type-Options: nosniffstops browsers guessing a MIME type you did not send.X-Frame-Options: SAMEORIGINallows framing from your own origin only.Referrer-Policy: strict-origin-when-cross-originkeeps your outbound referrals attributable without leaking full paths.
Directory listings and a 404 that returns 404
Disable directory listing emits Options -Indexes, which stops Apache generating a browsable file index for any folder without an index file. Those listings expose files nobody ever linked to, and Google indexes them happily.
Custom 404 writes an ErrorDocument 404 line. The path must be site-root-relative, like /404.html. This is the one field where a plausible-looking value breaks things: give it an absolute URL such as https://example.com/404.html and Apache stops serving the error document and issues a 302 to that URL instead. The page then returns 200. That is a soft 404, and it is worse than the default Apache error page because search engines will index it.
Before you overwrite the generated configuration file on the server
Back up the existing file first, somewhere outside the document root. Then confirm 3 things:
- The server reads the file.
.htaccessonly applies where the vhost setsAllowOverride All(or at minimumFileInfo). Otherwise it is read and silently ignored, which looks identical to a syntax error you cannot find. - The chain is one hop.
curl -sIL https://example.com/old-pricingprints every response in the chain. You want exactly one 301 landing on a 200. - Nothing upstream intercepts. On nginx none of this applies. Behind a CDN, edge rules may fire before Apache sees the request at all, so a redirect that "does not work" may be working perfectly under a rule you forgot about.
Every block sits inside its <IfModule> guard, so a host missing mod_deflate or mod_expires serves the site normally instead of returning a 500 on every request.