Safe .htaccess rules: backup, guards and rollback
Back up and roll back first
One wrong line in .htaccess answers 500 for the folder and everything below it, and the site may not give you a way to edit the file again through a browser. Keep a copy outside the document root before the first change:
mkdir -p ~/backups
cp -p ~/example.com/.htaccess ~/backups/example.com.htaccess.$(date +%Y%m%d-%H%M%S)
-p keeps the mode and the times. The timestamp in the name makes the rollback file easy to find. To undo a change, copy the last good file back over the broken one:
cp -p ~/backups/example.com.htaccess.20261107-120000 ~/example.com/.htaccess
If the host gives you no shell, download the file before editing it in the panel and keep the same dated copy on your machine.
Test in a subfolder
Never try a new directive on the home page of a live site. Make a throw-away folder, put the rule there and ask for that folder only:
mkdir -p ~/example.com/test-htaccess
vi ~/example.com/test-htaccess/.htaccess
RewriteEngine On RewriteRule ^probe$ /about/ [R=302,L] RewriteRule ^probe2$ https://%{HTTP_HOST}/about/ [R=302,L]
On a local Apache 2.4.68 with mod_php, the folder /test-htaccess/ on http://localhost:8080 shows the difference:
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8080/test-htaccess/probe
302 http://localhost:8080/about/
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8080/test-htaccess/probe2
302 https://localhost:8080/about/
The bare target builds http:// even though the proxy header says https; the absolute https://%{HTTP_HOST}/about/ target keeps https. A 302 is deliberate while testing: a cached 301 is the last thing you want. Flip it to 301 only when the rule is right.
RewriteEngine On in the folder switches off the parent rewrite rules there, unless the folder file adds RewriteOptions Inherit. That isolation is the point of the test folder. Remove it afterwards.
rm -rf ~/example.com/test-htaccess
Guards: turn a 500 into a skip
A guard makes a block conditional. If the condition is false, Apache skips the lines instead of stopping on an unknown directive. The cost is that a rule can silently do nothing, so every guard needs the check in the trade-off below.
IfModule
<IfModule> tests whether a module is loaded. It needs no extra module of its own:
<IfModule mod_php.c> php_flag display_errors off php_flag log_errors on </IfModule>
mod_php.c is the source file name of mod_php from PHP 8 on; PHP 7 calls it mod_php7.c. A negation handles the other branch:
<IfModule !mod_php.c> # PHP is not mod_php here: use .user.ini or the host file </IfModule>
This is the guard that fixes the classic host-move 500: the twelve php_flag and php_value lines are skipped on a FastCGI host instead of failing every request. The move that hit it, from Beget to DreamHost.
IfDirective
<IfDirective> tests one directive and works in Apache 2.4.34 and later. It is more precise than a module test when several modules can provide the same directive:
<IfDirective php_flag> php_value docref_ext ".zz" </IfDirective>
A directive the server knows runs; when it is missing, the block is skipped instead of answering 500. Confirmed on Apache 2.4.68 with mod_php, with a probe file next to the .htaccess:
<?php echo "docref_ext=", var_export(ini_get("docref_ext"), true), "\n";
curl -sS http://localhost:8080/test-htaccess/p.php
docref_ext='.zz'
On an older Apache this block is itself an unknown directive and answers 500, so use IfModule there.
If, ElseIf and Else
<If> evaluates an expression and needs Apache 2.4 or later:
<If "%{HTTPS} == 'off' && %{HTTP:X-Forwarded-Proto} != 'https'"> RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] </If> <Else> # Already https, or TLS ended at a proxy in front </Else>
<ElseIf> sits between them for a second condition. An expression can also choose between larger configuration blocks, not just one rule. Two limits matter:
- The expression syntax is not the same as a RewriteCond: it uses %{VARIABLE}, comparison operators and && / ||.
- Inside <If> use the one-argument Redirect, with the source in the condition: Redirect 301 "https://%{HTTP_HOST}/new/". The two-argument form does not evaluate the expression; it sends the literal %{HTTP_HOST} text, as the test below shows.
On Apache 2.4.68 with mod_php, the two-argument form inside <If> answered with the literal target:
<If "%{HTTP_HOST} =~ /localhost/"> Redirect 301 "/test-htaccess/old" "https://%{HTTP_HOST}/test-htaccess/new" </If>
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8080/test-htaccess/old
301 https://%{HTTP_HOST}/test-htaccess/new
The one-argument form works, because the source is in the condition:
<If "%{REQUEST_URI} == '/test-htaccess/old'"> Redirect 301 "https://%{HTTP_HOST}/test-htaccess/new" </If>
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8080/test-htaccess/old
301 https://localhost:8080/test-htaccess/new
A request for /test-htaccess/other answers 404: the condition is false and no redirect fires.
IfVersion
<IfVersion> tests the Apache version and needs mod_version. Wrap the version block itself in an IfModule if the module may be absent:
<IfModule mod_version.c> <IfVersion >= 2.4> # Apache 2.4 and later only </IfVersion> </IfModule>
IfDefine
<IfDefine> tests a name passed to Apache at start-up with -D, not a module or a version:
<IfDefine !MOD_PHP> # Included only when Apache was started without -DMOD_PHP </IfDefine>
A shared host rarely lets you change the start-up flags, so IfDefine is more useful on a server you control. For a hosting account, IfModule and If are the practical guards.
The trade-off: check the rule ran
A guard turns a loud 500 into a quiet skip. The site stays up, but the redirect or the header may be missing. After every guarded change, ask for the URL and look for the effect. On Apache 2.4.68 with mod_php a guard on a request header behaved like this:
RewriteEngine On <If "%{HTTP:X-Test} == 'go'"> RewriteRule ^ https://%{HTTP_HOST}/test-htaccess/target [R=301,L] </If> <Else> # nothing </Else>
With the guard true the rule fires:
curl -sS -o /dev/null -w "%{http_code} %{redirect_url}\n" -H 'X-Test: go' http://localhost:8080/test-htaccess/x
301 https://localhost:8080/test-htaccess/target
Without the header the guard is false and the same URL answers 404:
curl -sS -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8080/test-htaccess/x
404
A 301 with the new location means the block ran. A 404 from the old page means the guard was false: the quiet skip. A 500 means the guard itself is unsupported or the directive is outside it. The server check in Which server do you have tells you which of the three happened.
Where PHP settings go
php_flag and php_value exist only in mod_php. Under PHP-FPM or another FastCGI SAPI, put the same settings in a file PHP reads itself. The PHP manual describes .user.ini files: PHP scans them from the script folder up to the document root.
vi ~/example.com/.user.ini
display_errors = Off log_errors = On error_reporting = -1
Only settings changeable at PHP_INI_PERDIR or PHP_INI_USER level belong there. PHP caches the file for five minutes by default, and a running FastCGI worker keeps its old settings until it restarts.
Some hosts read a file of their own. DreamHost reads ~/.php/<version>/phprc:
vi ~/.php/8.5/phprc
display_startup_errors = Off display_errors = Off html_errors = Off log_errors = On error_reporting = -1
PHP reads that file when its process starts. Find the process name with pgrep (see Which PHP process runs), then end it so the next request starts a new one:
pkill -u user -x php85.cgi
Do not copy track_errors or log_errors_max_len into a PHP 8 file: they were removed in PHP 8.0 and 8.1. A probe file from the server check shows the values that are actually in force. To see the whole block run against both kinds of host before the move, build the hosting-like Docker images and run test 2.
A real case of the 500
The move to DreamHost is written up step by step in the php_flag 500 case; PHP errors: case studies is the index of such cases. That page has the log line, the twelve offending directives and the phprc fix. The short version: Apache read .htaccess, met php_flag, found no mod_php and answered 500 on every page; wrapping the block in <IfModule mod_php.c> brought the site back.
Article author: Arthur Isaev