500 on every page after a host move: php_flag under FastCGI

Symptom: 500 on every URL

After a PHP site moved to a DreamHost shared server, every address answered 500 Internal Server Error, the home page included. The hosts and the move: DreamHost shared hosting, from Beget to DreamHost. Tools used here: curl, grep. The response headers:

curl -sS -D - -o /dev/null https://example.com/

HTTP/2 500 content-length: 533 content-type: text/html; charset=iso-8859-1 date: Sat, 07 Nov 2026 07:25:20 GMT server: Apache

The body is the built-in text of Apache, "The server encountered an internal error or misconfiguration", in Latin-1. No PHP script wrote it: Apache failed the request itself, either on its configuration or on the PHP process. Its error log says which.

The error log names the line

DreamHost keeps the logs of each domain in the home folder of the domain user, one folder per protocol. Log in over SSH with the key you upload with:

ssh -i ~/.ssh/dreamhost-upload user@server.dreamhost.com

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

config.c means Apache stopped while it read its configuration, here the .htaccess in the docroot. php_flag and php_value are directives of mod_php, the module that runs PHP inside Apache. DreamHost runs PHP as a separate FastCGI process started by mod_fcgid, so Apache does not know these words. One unknown directive in a .htaccess fails every request in its folder and in all folders below it.

grep -n -E '^\s*php_(flag|value)' ~/example.com/.htaccess

78:php_flag display_startup_errors off 79:php_flag display_errors off 80:php_flag html_errors off 81:php_flag log_errors on 82:php_flag ignore_repeated_errors off 83:php_flag ignore_repeated_source off 84:php_flag report_memleaks on 85:php_flag track_errors on 86:php_value docref_root 0 87:php_value docref_ext 0 91:php_value error_reporting -1 92:php_value log_errors_max_len 0

Twelve lines, all from the block that sets up error logging. The old hosting (Beget) ran PHP as mod_php and read them without complaint.

Ruled out: permissions and the upload

  1. File permissions. A support chat answered the 500 with chmod 755 for folders and chmod 644 for files. They already were set that way, and a permissions problem has a log line of its own: Permission denied with pcfg_openfile: unable to check htaccess file.
  2. The last upload. The 500 seemed to start after the files were copied up with rsync, but the oldest log, begun when the domain went live on the new server, already opens with the same error.

head -n 1 ~/logs/example.com/https/error.log.2026-11-05

[Fri Nov 06 00:25:53 2026] [example.com] [alert] [client 198.51.100.24:12595] [pid 1202947] 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

The first request the new server ever logged already failed. A browser that still showed the site after the move got it from the old server or from its cache.

Fix: a mod_php-only block plus phprc

Keep a copy of the file outside the docroot first:

mkdir -p ~/backups

cp -p ~/example.com/.htaccess ~/backups/example.com.htaccess.$(date +%Y%m%d-%H%M%S)

Then wrap the twelve lines in IfModule, so Apache reads them only where mod_php is loaded:

vi ~/example.com/.htaccess

<IfModule mod_php.c> php_flag display_startup_errors off php_flag display_errors off php_flag html_errors off php_flag log_errors on php_flag ignore_repeated_errors off php_flag ignore_repeated_source off php_flag report_memleaks on php_flag track_errors on php_value docref_root 0 php_value docref_ext 0 php_value error_reporting -1 php_value log_errors_max_len 0 </IfModule>

mod_php.c is the source file name of mod_php from PHP 8 on; PHP 7 calls it mod_php7.c. The site answered again at once. The same file still works where mod_php is loaded: in a local php:8.5-apache container a probe saw docref_root set to 0, a value only this block sets.

Under FastCGI Apache now skips the block, and PHP runs with the defaults of the host. A probe file shows them. Give it a name nobody guesses and delete it afterwards:

vi ~/example.com/probe-7f3a.php

<?php echo PHP_VERSION, " sapi=", PHP_SAPI, " display_errors=", var_export(ini_get("display_errors"), true), " log_errors=", ini_get("log_errors"), " error_reporting=", ini_get("error_reporting"), " error_log=", ini_get("error_log"), " ini=", php_ini_loaded_file(), " scanned=", php_ini_scanned_files();

curl -sS https://example.com/probe-7f3a.php

8.5.12 sapi=cgi-fcgi display_errors='0' log_errors=1 error_reporting=22517 error_log= ini=/etc/php85/php.ini scanned=/etc/php85/conf.d/imagick.ini

Display is off and logging is on, so visitors see no error text. But 22517 on PHP 8.5 is E_ALL without E_WARNING, E_NOTICE and E_DEPRECATED: warnings never reach the log. DreamHost reads extra settings for each PHP version from an ini file in the home folder, ~/.php/<version>/phprc:

vi ~/.php/8.5/phprc

display_startup_errors = Off display_errors = Off html_errors = Off log_errors = On ignore_repeated_errors = Off ignore_repeated_source = Off report_memleaks = On error_reporting = -1

track_errors (removed in PHP 8.0) and log_errors_max_len (removed in 8.1) stay out. PHP reads phprc when its process starts. Ending the running process makes the next request start a new one:

pkill -u user -x php85.cgi

curl -sS https://example.com/probe-7f3a.php

8.5.12 sapi=cgi-fcgi display_errors='' log_errors=1 error_reporting=-1 error_log= ini=/etc/php85/php.ini scanned=/etc/php85/conf.d/imagick.ini, /home/user/.php/8.5/phprc

phprc is in the scanned list and error_reporting is -1. display_errors reads '' instead of '0': both mean off. Remove the probe:

rm ~/example.com/probe-7f3a.php

With error_log empty, PHP messages land in the same Apache error log, marked mod_fcgid: stderr:

[Sat Nov 07 01:01:52 2026] [example.com] [warn] [client 203.0.113.9:61816] [pid 1836802] fcgid_bucket.c(153): mod_fcgid: stderr: PHP Deprecated: Creation of dynamic property Related::$urls is deprecated in /home/user/example.com/.php/Related.php on line 10

Second symptom: a redirect loop

With the 500 gone, every address answered 301, and the target was the address itself:

curl -sS -o /dev/null -w "%{http_code} %{redirect_url}\n" https://example.com/

301 https://example.com/

curl -sS -L -o /dev/null https://example.com/

curl: (47) Maximum (50) redirects followed

The loop comes from the HTTPS rule at the top of the .htaccess:

RewriteCond %{HTTP:X-Forwarded-Proto} !https RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]

On the old hosting TLS ended at an nginx front server, which passed each request on to Apache over plain HTTP with the header X-Forwarded-Proto: https, as Redirects behind an HTTPS proxy describes. DreamHost ends TLS in Apache itself and sends no such header, so "not https" held for every request, https ones included.

Fix: check HTTPS as well

Add a condition on %{HTTPS}, the TLS state Apache itself sees:

vi ~/example.com/.htaccess

RewriteCond %{HTTPS} off RewriteCond %{HTTP:X-Forwarded-Proto} !https RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]

All conditions above a rule must hold, so the redirect now fires only when both say plain HTTP:

Browsers keep 301 answers in their cache. A browser that met the loop may go on showing "too many redirects" after the fix: check in a private window, or clear the data of the site.

Check the result

One plain http address, two pages and one address that does not exist:

for u in http://example.com/ https://example.com/ https://example.com/about/ https://example.com/no-such-page-xyz/; do curl -sS -o /dev/null -w "%{http_code} %{redirect_url} <- $u\n" "$u"; done

301 https://example.com/ <- http://example.com/ 200 <- https://example.com/ 200 <- https://example.com/about/ 404 <- https://example.com/no-such-page-xyz/

One hop from http to https, pages at 200, the missing page at 404. The same two edits went into the .htaccess in the repository, or the next deploy would have brought the 500 back. To run the same two checks before the move, against a local server with the DreamHost web layer, see the hosting-like Docker images and their test plan.

Article author: Klavdiya Kondakova