500 на каждой странице после переезда: php_flag под FastCGI

Симптом: 500 на каждом URL

После переезда сайта на PHP на общий сервер DreamHost каждый адрес отвечал 500 Internal Server Error, главная страница в том числе. Хостинги и переезд: общий хостинг DreamHost, с Beget на DreamHost. Инструменты, которые понадобятся: curl, grep. Заголовки ответа:

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

Тело — встроенный текст Apache, «The server encountered an internal error or misconfiguration», в Latin-1. Его не писала ни одна программа на PHP: Apache сам провалил запрос, либо на своей конфигурации, либо на процессе PHP. Какой именно — говорит лог ошибок.

Лог ошибок называет строку

DreamHost хранит логи каждого домена в домашней папке пользователя домена, по папке на протокол. Войдите по SSH с ключом, которым загружаете файлы:

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 значит, что Apache остановился, читая свою конфигурацию, здесь — .htaccess в корне документов. php_flag и php_value — директивы mod_php, модуля, который запускает PHP внутри Apache. DreamHost запускает PHP отдельным процессом FastCGI под управлением mod_fcgid, поэтому Apache не знает этих слов. Одна неизвестная директива в .htaccess валит каждый запрос в её папке и во всех папках ниже.

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

Двенадцать строк, все из блока, который настраивает запись ошибок. Старый хостинг (Beget) запускал PHP как mod_php и читал их без возражений.

Что исключили: права и загрузка

  1. Права файлов. Поддержка в чате ответила на 500 советом chmod 755 для папок и chmod 644 для файлов. Они уже были такими, а у проблемы с правами своя строка в логе: Permission denied вместе с pcfg_openfile: unable to check htaccess file.
  2. Последняя загрузка. Казалось, 500 началось после того, как файлы скопировали через rsync, но самый старый лог, начатый, когда домен заработал на новом сервере, уже открывается той же ошибкой.

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

Первый запрос, который новый сервер вообще записал, уже провалился. Браузер, который ещё показывал сайт после переезда, получал его со старого сервера или из кеша.

Исправление: блок только для mod_php и phprc

Сначала сохраните копию файла вне корня документов:

mkdir -p ~/backups

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

Затем оберните двенадцать строк в IfModule, чтобы Apache читал их только там, где загружен mod_php:

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 — имя исходного файла mod_php начиная с PHP 8; в PHP 7 он называется mod_php7.c. Сайт сразу ответил снова. Тот же файл работает и там, где mod_php загружен: в локальном контейнере php:8.5-apache проба увидела docref_root со значением 0, а его ставит только этот блок.

Под FastCGI Apache теперь пропускает блок, и PHP работает с умолчаниями хостинга. Их показывает пробный файл. Дайте ему имя, которое никто не угадает, и удалите после:

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

Показ ошибок выключен, запись включена, поэтому посетители не видят текста ошибок. Но 22517 на PHP 8.5 — это E_ALL без E_WARNING, E_NOTICE и E_DEPRECATED: предупреждения вообще не попадают в лог. DreamHost читает дополнительные настройки для каждой версии PHP из ini-файла в домашней папке, ~/.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 (удалён в PHP 8.0) и log_errors_max_len (удалён в 8.1) остаются за бортом. PHP читает phprc, когда его процесс запускается. Завершите работающий процесс, и следующий запрос запустит новый:

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 есть в списке scanned, а error_reporting равно -1. display_errors читается как '' вместо '0': оба значат «выключено». Удалите пробный файл:

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

При пустом error_log сообщения PHP попадают в тот же лог ошибок Apache с пометкой 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

Второй симптом: цикл редиректов

Когда 500 не стало, каждый адрес отвечал 301, а целью был он сам:

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

Цикл даёт правило HTTPS в начале .htaccess:

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

На старом хостинге TLS заканчивался на фронтенде nginx, который передавал каждый запрос в Apache по простому HTTP с заголовком X-Forwarded-Proto: https, как описывает статья Редиректы за HTTPS-прокси. DreamHost завершает TLS в самом Apache и такого заголовка не присылает, поэтому «не https» было верно для каждого запроса, включая https.

Исправление: проверять ещё и HTTPS

Добавьте условие на %{HTTPS} — состояние TLS, которое видит сам Apache:

vi ~/example.com/.htaccess

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

Все условия над правилом должны выполняться, поэтому редирект теперь срабатывает, только когда оба говорят «простой HTTP»:

Браузеры хранят ответы 301 в кеше. Браузер, который встретил цикл, может продолжать показывать «слишком много перенаправлений» и после исправления: проверьте в приватном окне или очистите данные сайта.

Проверка результата

Один простой http-адрес, две страницы и один несуществующий адрес:

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/

Один переход с http на https, страницы отвечают 200, пропавшая — 404. Те же две правки попали в .htaccess в репозитории, иначе следующая выкладка вернула бы 500. Чтобы прогнать те же две проверки до переезда, на локальном сервере с веб-слоем DreamHost, смотрите образы Docker, похожие на хостинг, и их план проверок.

Авторы статьи: Клавдия Кондакова, Arthur Isaev