Which server do you have: check before writing .htaccess
Response headers
Start with one request and read the server header. Tools used here: curl, SSH, grep.
curl -sSI https://example.com/
HTTP/2 200 content-type: text/html; charset=UTF-8 server: Apache
The header is a hint, not a diagnosis. A proxy in front hides the real server, and a host may replace or drop the header. It does not tell you whether PHP runs as mod_php or as a separate process, and that is the difference that turns a working .htaccess into a 500.
A probe PHP file
A tiny PHP file answers the rest. Give it a name nobody guesses, and delete it when you are done:
vi ~/example.com/probe-7f3a.php
<?php
echo "HTTPS=", var_export($_SERVER['HTTPS'] ?? null, true),
" XFP=", var_export($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? null, true),
" SERVER_SOFTWARE=", $_SERVER['SERVER_SOFTWARE'] ?? '',
" sapi=", PHP_SAPI, "\n";
curl -sS https://example.com/probe-7f3a.php
On a FastCGI host (DreamHost) the probe answers:
HTTPS='on' XFP=NULL SERVER_SOFTWARE=Apache sapi=cgi-fcgi
rm ~/example.com/probe-7f3a.php
In that answer HTTPS (TLS) ends in Apache (HTTPS=on), no proxy header is present (XFP=NULL) and PHP is not an Apache module (sapi=cgi-fcgi). The next section explains the three SAPI values.
What PHP_SAPI means
| Value | How PHP runs | What it means for .htaccess |
|---|---|---|
| apache2handler | mod_php inside Apache | php_flag and php_value work. |
| cgi-fcgi | a CGI or FastCGI process, often mod_fcgid or a wrapper such as php85.cgi | php_flag is unknown: guard it or move the setting to .user.ini or the host file. |
| fpm-fcgi | PHP-FPM | php_flag is unknown: use .user.ini or the pool configuration. |
| cli | the command line, not a web request | No .htaccess at all: a probe run from a shell proves nothing about the web server. |
The same values decide where PHP settings belong. mod_php reads php_flag from .htaccess; CGI, FastCGI and PHP-FPM read .user.ini or the ini file of the host. Safe rules has the exact files and the restart commands.
Apache modules
On a local Docker php:8.5-apache server with mod_php, ask Apache itself which modules are loaded:
apache2ctl -M | grep -i -E 'php|rewrite|alias|headers|version'
version_module (static) alias_module (shared) headers_module (shared) php_module (shared) rewrite_module (shared)
php_module (shared) is mod_php. There is no php_module line on a FastCGI host, even though Apache is still Apache. alias_module is needed by Redirect and RedirectMatch, rewrite_module by RewriteRule, headers_module by Header, and version_module by IfVersion. A missing module is exactly what the guards in Safe rules are for. A shared host usually gives no apache2ctl, so there the probe file and the host panel are the only answer.
Which PHP process runs
When Apache is only the front end, the PHP process has its own name. On a FastCGI host (DreamHost) it looks like this:
pgrep -l -u user php
2745746 php85.cgi
ls /usr/sbin/apache2*
/usr/sbin/apache2 /usr/sbin/apache2ctl
php85.cgi is a FastCGI worker, so PHP is not inside Apache. A host that runs PHP-FPM shows a process named php-fpm instead. The presence of apache2ctl confirms Apache, but it does not confirm mod_php.
Where the error log is
A broken rule is not silent. Apache writes the file name and the directive to its error log:
tail -n 1 ~/logs/example.com/https/error.log
[Fri Nov 06 23:29:54 2026] [example.com] [alert] [client 203.0.113.7:37118] [pid 3064457] config.c(2045): /home/user/example.com/.htaccess: Invalid command 'php_flag', perhaps misspelled or defined by a module not included in the server configuration
On a shared host the log usually sits in the home folder, one folder per protocol; on Debian and Ubuntu it is often /var/log/apache2/error.log; a control panel shows the path in its log viewer. Ask the host if none of these exist. The line config.c means Apache stopped while reading its configuration, and the path after it names the exact file. A real case of this 500 is in PHP errors: case studies.
Is .htaccess read at all
nginx never reads .htaccess, and Apache can be told to ignore it with AllowOverride None. Test in a throw-away subfolder, never on the home page. A deliberate bad line answers 500 only there:
mkdir -p ~/example.com/test-htaccess
vi ~/example.com/test-htaccess/.htaccess
ThisDirectiveDoesNotExist on
On a local Apache 2.4.68 with mod_php, the test folder /test-htaccess/ on http://localhost:8080 answers 500, while its parent still answers 200:
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:8080/test-htaccess/
500
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:8080/
200
The 500 in the test folder and the 200 in its parent mean the server reads .htaccess here: the bad line is the only cause. A 500 on both points at the server configuration instead; 200 on both means .htaccess is ignored in this folder, and no redirect written there can work. Remove the test folder either way:
rm -rf ~/example.com/test-htaccess
A redirect is the other safe probe: add one rule in the test folder and ask for its source with curl -sI. A 301 proves the file is read; a 404 proves it is not. This test changes the answer of that one folder only.
Where TLS ends
The probe answers this without a guess:
- HTTPS='on': Apache itself ends TLS, as on DreamHost or a VPS with certbot. A bare Redirect target keeps https.
- HTTPS empty and XFP='https': a proxy in front ends TLS, as on Beget, and Apache sees plain HTTP with the header X-Forwarded-Proto: https. A bare target becomes http://; use an absolute target or the rewrite pair from Redirect 301 or the pair.
- Both empty on an https address: TLS may end at a proxy that sends no header, or the probe itself is misconfigured. Do not build an https rule on that answer alone; ask the host.
The full story of a proxied Apache, the extra http hop and the two conditions that stop a loop is in Redirects behind an HTTPS proxy.
Article author: Arthur Isaev