031 —Architecture
Reading inherited .htaccess files: a fundamentals guide
Inherited a site whose .htaccess starts with three commented blocks of dead RewriteRules in Dutch? Read it block by block. This is the order we use.
The first five minutes
It is 16:20 on a Wednesday. A client forwards an email from their hosting provider: "Your site failed our PHP 8.2 compatibility scan." You SFTP into the document root, open .htaccess, and find 340 lines. Half are commented out in Dutch. There are three separate WordPress rewrite blocks, two of them stale. Somewhere in there is a single mod_security rule that has been silently 403'ing the contact form for a month.
Before touching anything, copy the file off-server. Name it htaccess-baseline-2026-05-26.txt and drop it in a notes folder. Every edit from now on is a diff against that baseline. If something breaks in twenty minutes, you want to be able to scp the original back in one command, not reconstruct it from memory.
Then read top to bottom, slowly. The .htaccess file is parsed directive by directive, in order, and the order matters. The last matching rule for a given response header wins. The first matching RewriteRule with the [L] flag stops the chain. A file that looks fine can do nothing because a Redirect 301 on the second line short-circuits everything below it.
The block taxonomy
Most inherited .htaccess files are five blocks taped together, in roughly this order:
- PHP handler switch (
AddHandler/SetHandler), often left behind by cPanel or DirectAdmin's PHP selector. - The CMS rewrite block, bracketed by
# BEGIN/# ENDmarkers. - Caching plugin debris (W3 Total Cache, WP Super Cache, WP Rocket).
- Security hardening: deny-from rules, wp-config protection, mod_security tweaks.
- Site-specific redirects: 301s accumulated over migrations.
Read it as five files glued together, not one. Each block has its own author, its own decade, its own bug. The CMS block is the one you must not touch by hand. The other four are where your work is.
The PHP handler line
On shared hosting the top of the file usually looks like this:
# Use PHP 7.4
AddHandler application/x-httpd-ea-php74 .php
If you are migrating to PHP 8.2, this line is a trap. The host's MultiPHP UI will appear to set the version correctly while .htaccess silently overrides it. The fix is to comment the line out, save, reload the site, and confirm in a phpinfo() page that the version actually flipped. Apache's docs on the AddHandler directive are worth a re-read if the syntax looks unfamiliar.
The CMS rewrite block
A standard WordPress block looks like this:
# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress
Six lines. That is the whole legitimate WordPress rewrite block. Anything inside the BEGIN/END markers that does not match this template was added by a plugin or by a previous developer who treated .htaccess like a scratch pad. WordPress's own permalinks documentation confirms it: the core block does not change between releases.
If you see two BEGIN WordPress blocks in the same file, one of them is dead. Usually the second one. Delete it, save, hard-reload the front page and one permalink. If both still load, the cut was clean.
Reading rewrite rules without losing your mind
The block that breaks the most brains is the rewrite chain. Read each rule as four parts:
- The conditions (every
RewriteCondabove it, until the previousRewriteRule). - The match pattern (the regex after
RewriteRule). - The substitution (the URL it rewrites to).
- The flags in square brackets.
So a rule like this:
RewriteCond %{HTTP_HOST} ^www\.oldsite\.nl$ [NC]
RewriteRule ^(.*)$ https://newsite.nl/$1 [R=301,L]
reads as: "if the host header is www.oldsite.nl, case-insensitive, then redirect every path to the same path on newsite.nl with a 301, and stop processing." The [L] is the part developers forget. Without it, the next rule can rewrite again, sometimes producing the dreaded /index.php/index.php loop.
Two flags account for most production fires: [L] (last, stop the chain) and [R=301] (permanent redirect, gets cached by browsers for months). A wrong 301 is a long-tail incident, because users with the cached redirect cannot reach the new behaviour until they clear their browser. Be sure before you push.
The dangerous quiet
The lines that ship outages are not the loud ones. They are the quiet ones that look harmless.
A Header set X-Frame-Options "SAMEORIGIN" added years ago can break a new iframe embed for a partner site. A <Files wp-config.php> block that was copy-pasted with the wrong filename (wpconfig.php) protects nothing. A RewriteRule ^wp-admin/ - [F] left behind from a hack-recovery in 2019 will 403 the legitimate admin once the IP allow-list above it expires.
When auditing, search the file for three patterns first:
grep -nE '^\s*(Redirect|RedirectMatch)\s' .htaccess
grep -nE '\[F\]|\[G\]|\[R=' .htaccess
grep -nE 'Header\s+(set|append|add)' .htaccess
Those three commands surface the rules most likely to be silently shaping traffic. Read each match in context. If you cannot explain in one sentence why it is there, it is a candidate for removal, but not yet. Comment it out first with a date stamp:
# 2026-05-26 BM: disabled, suspect stale hotlink block
# RewriteCond %{HTTP_REFERER} !^https?://(www\.)?site\.nl [NC]
# RewriteRule \.(jpg|jpeg|png|gif)$ - [F]
A dated comment is a tombstone. Six months from now, when nobody complains, you delete the block. Until then, the comment tells the next person what you suspected and when.
The kill-switch test
The fastest way to know whether .htaccess is the source of an incident is the kill switch. Rename it:
mv .htaccess .htaccess.disabled
Reload the site. If the symptom disappears, .htaccess owns the bug. If the symptom remains, look elsewhere (PHP, the database, the load balancer). Then rename it back and binary-search: comment out the bottom half, reload, then the bottom quarter, until the offending block is alone.
This is crude, and it costs you a few seconds of broken permalinks on a live site. But it is decisive in a way that reading the file is not. The Apache project documents the order of section and directive processing for the cases where the bug is not in one block but in the interaction between two.
What we ended up building
When we built Pier we ran into this exact thing on almost every onboarding to a legacy site: an .htaccess that was an archaeological dig. The way we ended up handling it was to give every saved version of the file a timestamped diff in version history, so that a kill-switch test followed by twenty edits can be rolled back in one click instead of an SCP scramble.
Pick the smallest version of this you can do today: open one .htaccess you have not read in a year, save a baseline copy off-server, and run the three grep commands above against it. Whatever turns up in the next five minutes is the start of your audit.
— Questions —
What happens if I delete .htaccess entirely?
WordPress regenerates the core block on the next permalink save, but every custom redirect, security rule, and PHP handler line is gone for good. Copy the file off-server before you touch it.
How do I find which line is causing a 500 error?
Rename .htaccess to .htaccess.disabled and reload. If the 500 stops, binary-search by commenting halves of the file until the offending block is alone.
Are .htaccess rewrite rules case-sensitive?
Yes by default. Use the [NC] flag on RewriteCond and RewriteRule to make them case-insensitive. Hostnames and paths are matched literally otherwise.
Should I move rules into the main server config instead?
If you control the vhost, yes. The .htaccess file is parsed on every request and is measurably slower. On shared hosting you usually have no choice but to keep it.