Apache’s own rewrite log is the correct answer, and on a lot of hosting you cannot switch it on. Here is what it does, why it is often unavailable, and what to do instead when the file you are debugging is already open in your editor.
You change a RewriteRule, push it, load the URL, and get a 404. Or two redirects.
Or a 500 that says nothing useful. So you change it again and push again. Each turn of that loop
costs a deploy and tells you one bit: it still doesn’t work.
What you actually want to know is narrower: which rule matched, which
RewriteCond failed and what value it was tested against, and what the path had become
by the time Apache gave up. There are three places to get that.
If you have server configuration access, this is the ground truth and nothing here replaces it. In Apache 2.4 the old directives are gone:
“Those familiar with earlier versions of mod_rewrite will no doubt be looking
for the RewriteLog and RewriteLogLevel directives. Since the release of
httpd 2.4, these directives have been replaced with the per-module logging configuration provided
by LogLevel.”
— Apache HTTP Server
documentation, mod_rewrite
The replacement is one directive:
LogLevel alert rewrite:trace3
Rewrite tracing then appears in the error log. mod_rewrite logs at the
trace1 to trace8 levels; up to debug no actions are logged
at all, and at trace8, in Apache’s words, “practically all actions are
logged”. To read only the rewrite lines, the documentation suggests
tail -f error_log|fgrep '[rewrite:'.
Two things stop people using it, and both are in Apache’s own documentation rather than folklore.
LogLevel
lists its context as server config, virtual host, directory —
.htaccess is not in that list. So on shared hosting, where the
.htaccess file is the only Apache configuration you are allowed to write, you cannot
turn rewrite tracing on at all. That is not a limitation of your host being unhelpful; it is the
directive’s defined context in
mod_core.mod_rewrite will slow down your Apache HTTP Server dramatically!
Use a log level higher than trace2 only for debugging!” Which is fine on a
laptop and a decision on a production box.And even when you have it, the log is downstream of a deploy. It tells you what happened after you shipped the guess.
Browsers cache redirects aggressively — a 301 you have since fixed will keep
firing from cache and convince you your new rule is broken. Ask the server directly instead:
curl -sSIL -o /dev/null -w '%{http_code} %{url_effective}\n' https://example.com/blog/hello-world
-L follows the redirect chain and -w prints where it ended up, so a
redirect loop shows as a chain rather than as a blank page. This is worth doing before anything
else, because it distinguishes “my rule is wrong” from “my browser remembers the
old one”. It still needs the rules deployed somewhere.
The third option is to run the ruleset against a URL locally and read the trace. Nothing is deployed, so the loop is as fast as typing.
Two ways to do that here, both free:
In the browser, with nothing installed:
the .htaccess tester. Paste a file and a URL, get every
RewriteCond with the value it was actually tested against and every
RewriteRule that matched or didn’t. It runs entirely in your browser —
your .htaccess is not uploaded, not logged and not sent anywhere, which matters
because production rewrite files tend to carry internal paths, admin URLs, staging hostnames and
IP allowlists.
From a terminal: the same engine, MIT-licensed, as
npx htaccess-trace .htaccess /blog/hello-world —
github.com/theseusbuilds/htaccess-trace.
In the IDE: .htaccess Pro runs that engine against the file open in your editor, and double-clicking a line of the trace jumps to that line of the file. It is free while in early access.
Most rules that “do nothing” fail for one of a small number of reasons, and the useful part of a trace is that it names which one you have rather than leaving you to guess:
[L]. Processing of that pass
stopped before your rule was ever considered.RewriteCond failed, and the string it was tested against is not
the string you wrote — %{REQUEST_URI}, %{HTTP_HOST} and friends are
expanded first. Reading the expanded value is usually the whole answer..htaccess, Apache strips the directory prefix before matching, so
^/blog matches nothing, ever.There is a worked example of each on the tester page — including the loop, which is the one that is hardest to see by reading.
Stated here rather than discovered later. A simulation is a model of Apache, not Apache.
RewriteCond %{REQUEST_FILENAME} !-f
cannot be answered without your server’s filesystem, so the simulator assumes the file does
not exist and says so on the line. That is the common case for a front controller and the wrong
case when you are debugging a rule that depends on a file existing.RewriteBase, RewriteMap,
proxying and server-config context are not modelled.So the honest ordering is: simulate to find the rule that is wrong, deploy, then confirm with
curl — and if the answers disagree, believe the server and
send me the file, because a file this gets wrong is the
most useful thing anyone sends me.
Any JetBrains IDE on build 242 or later — IntelliJ IDEA (including Community), PhpStorm, WebStorm, PyCharm, RubyMine, GoLand, Rider, CLion.
In your IDE: Settings → Plugins → ⚙ → Manage Plugin Repositories… → + and add
https://theseusbuilds.dev/htaccess-pro/updatePlugins.xml
Then find .htaccess Pro under the Marketplace tab and install it. No account, no email address, no card. Full details on the .htaccess Pro page.