Проверить сайт на сервере, похожем на Beget или DreamHost

В репозитории есть два Docker-образа, которые копируют веб-слой двух общих хостингов: Beget и DreamHost. Смонтируйте корень документов в один из них, и тот же код, тот же .htaccess и те же права файлов встретят сервер, который ведёт себя как настоящий. Инструменты, которые понадобятся: Docker, curl, SSH.

Что копируют образы

Оба образа монтируют сайт только для чтения в /var/www/html, и владелец у него — не Apache, а другой пользователь. Поэтому папка с правами 700 внутри контейнера падает точно так же, как падает на хостинге.

ЧтоПохожий на BegetПохожий на DreamHost
Фронтенд-серверnginx на порту 80, Apache на 8080только Apache
Статические файлы на дискеих отдаёт nginx, до Apache не доходятих отдаёт Apache
Режим PHPPHP как mod_php, sapi=apache2handlermod_fcgid и php85.cgi, sapi=cgi-fcgi
php_flagработаетнеизвестная директива, каждый запрос 500
TLSнет, только httpсамоподписанный сертификат во время сборки
X-Forwarded-Protoего ставит nginxего нет, HTTPS=on
.htaccessAllowOverride AllAllowOverride All
Настройки PHPв .htaccess/var/www/.php/8.5/phprc
Журналыжурнал Apache внутри контейнера/logs/example.com/http(s)/

Чего образы скопировать не могут: панель управления, почту, DNS, квоты, настоящие IP-адреса, Passenger для Python на Beget, расширения PHP сверх стандартных (например, драйвер MySQL) и точные версии пакетов конкретного месяца. Они проверяют веб-слой, а не аккаунт.

Файлы

Всё лежит в container/containerfiles/hosting/:

container/containerfiles/hosting/ ├── compose.yml ├── beget/ │ ├── Dockerfile │ ├── apache-site.conf │ ├── nginx-beget.conf │ └── entrypoint.sh └── dreamhost/ ├── Dockerfile ├── apache-site.conf └── entrypoint.sh

Фронтенд-сервер образа, похожего на Beget:

cat container/containerfiles/hosting/beget/nginx-beget.conf

# The front server of the Beget-like image. # # An existing static file is served from disk by nginx and never reaches # Apache, so an .htaccess redirect for that path does not run - the same # behaviour as the real host. Everything else goes to Apache with the # X-Forwarded-Proto header set. server { listen 80 default_server; listen [::]:80 default_server; server_name _; server_tokens off; root /var/www/html; index index.php index.html; location ~* \.(jpe?g|png|webp|gif|svg|css|js|txt|ico|woff2|pdf)$ { try_files $uri @apache; } location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location @apache { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }

Сторона Apache того же образа:

cat container/containerfiles/hosting/beget/apache-site.conf

# Apache behind nginx, as on Beget. Port 8080 is private to the container: # nginx listens on 80 and proxies everything except an existing static file. <VirtualHost *:8080> ServerName example.com ServerAlias www.example.com localhost DocumentRoot /var/www/html <Directory /var/www/html> Options -Indexes +FollowSymLinks AllowOverride All Require all granted </Directory> ErrorLog ${APACHE_LOG_DIR}/error.log CustomLog ${APACHE_LOG_DIR}/access.log combined </VirtualHost>

Точка входа запускает Apache и держит nginx на переднем плане:

cat container/containerfiles/hosting/beget/entrypoint.sh

#!/bin/sh # Start Apache in the background, then keep nginx in the foreground. set -e # /run is a tmpfs in a container, so recreate Apache's runtime folders. mkdir -p /var/run/apache2 /var/lock/apache2 chown www-data:www-data /var/run/apache2 /var/lock/apache2 apache2ctl start exec nginx -g 'daemon off;'

Dockerfile образа, похожего на Beget:

cat container/containerfiles/hosting/beget/Dockerfile

# Beget-like shared hosting: nginx in front, Apache with mod_php behind. # Build with a different PHP version: # docker build --build-arg PHP_VER=8.2 -t hosting-like-beget:8.2 beget/ ARG PHP_VER=8.5 FROM php:${PHP_VER}-apache ENV DEBIAN_FRONTEND=noninteractive # nginx is the front server; Apache and mod_php come from the base image. RUN apt-get update \ && apt-get install -y --no-install-recommends nginx-light \ && rm -rf /var/lib/apt/lists/* # Apache listens on 8080 behind nginx. mod_php is already enabled in the # php:apache image, which is what makes php_flag work here. RUN a2enmod rewrite headers \ && sed -ri 's/^Listen 80$/Listen 8080/' /etc/apache2/ports.conf \ && a2dissite 000-default COPY apache-site.conf /etc/apache2/sites-available/beget.conf RUN a2ensite beget COPY nginx-beget.conf /etc/nginx/sites-available/beget.conf RUN rm -f /etc/nginx/sites-enabled/default \ && ln -s /etc/nginx/sites-available/beget.conf /etc/nginx/sites-enabled/beget.conf COPY entrypoint.sh /usr/local/bin/beget-entrypoint RUN chmod 755 /usr/local/bin/beget-entrypoint RUN mkdir -p /var/www/html \ && chown www-data:www-data /var/www/html EXPOSE 80 ENTRYPOINT ["beget-entrypoint"]

Apache образа, похожего на DreamHost, где нет mod_php и добавлен обработчик FastCGI:

cat container/containerfiles/hosting/dreamhost/apache-site.conf

# DreamHost-like Apache. @PHP_CGI@ and @PHP_VER@ are replaced at build time. # PHP is a FastCGI process, not mod_php: php_flag in .htaccess fails here. <VirtualHost *:80> ServerName example.com ServerAlias www.example.com localhost DocumentRoot /var/www/html <Directory /var/www/html> Options -Indexes +FollowSymLinks +ExecCGI AllowOverride All Require all granted </Directory> AddHandler fcgid-script .php FcgidWrapper /usr/local/bin/@PHP_CGI@ .php ErrorLog /logs/example.com/http/error.log CustomLog /logs/example.com/http/access.log combined </VirtualHost> <IfModule mod_ssl.c> <VirtualHost *:443> ServerName example.com ServerAlias www.example.com localhost DocumentRoot /var/www/html SSLEngine on SSLCertificateFile /etc/ssl/example.com/cert.pem SSLCertificateKeyFile /etc/ssl/example.com/key.pem <Directory /var/www/html> Options -Indexes +FollowSymLinks +ExecCGI AllowOverride All Require all granted </Directory> AddHandler fcgid-script .php FcgidWrapper /usr/local/bin/@PHP_CGI@ .php ErrorLog /logs/example.com/https/error.log CustomLog /logs/example.com/https/access.log combined </VirtualHost> </IfModule>

Dockerfile образа, похожего на DreamHost:

cat container/containerfiles/hosting/dreamhost/Dockerfile

# DreamHost-like shared hosting: Apache alone, PHP as FastCGI through # mod_fcgid. There is no mod_php, so php_flag in .htaccess answers 500. # Build with another PHP version: # docker build --build-arg PHP_VER=8.3 -t hosting-like-dreamhost:8.3 dreamhost/ ARG PHP_VER=8.5 FROM debian:bookworm-slim ARG PHP_VER ENV DEBIAN_FRONTEND=noninteractive # Apache from Debian, PHP CGI from the Sury repository. The official # php:apache image is built with --disable-cgi, so it has no php-cgi and # cannot serve as a FastCGI host. RUN set -eux; \ apt-get update; \ apt-get install -y --no-install-recommends \ apache2 \ libapache2-mod-fcgid \ ca-certificates \ curl \ gnupg \ openssl \ procps; \ install -d /usr/share/keyrings; \ curl -fsSL https://packages.sury.org/php/apt.gpg \ -o /usr/share/keyrings/sury-php.gpg; \ echo "deb [signed-by=/usr/share/keyrings/sury-php.gpg] https://packages.sury.org/php/ bookworm main" \ > /etc/apt/sources.list.d/sury-php.list; \ apt-get update; \ apt-get install -y --no-install-recommends "php${PHP_VER}-cgi"; \ rm -rf /var/lib/apt/lists/* # mod_fcgid runs with prefork; mod_php is absent by design. RUN printf 'ServerName localhost\n' > /etc/apache2/conf-available/servername.conf \ && a2enconf servername \ && sed -ri -e 's/^ServerTokens .*/ServerTokens Prod/' -e 's/^ServerSignature .*/ServerSignature Off/' /etc/apache2/conf-available/security.conf \ && a2dismod mpm_event \ && a2enmod mpm_prefork \ && a2enmod fcgid ssl rewrite headers \ && a2dissite 000-default # A copy of php-cgi named as on the real host, so pkill -x php85.cgi works. RUN set -eux; \ php_tag="$(printf '%s' "$PHP_VER" | tr -d '.')"; \ test -x "/usr/bin/php-cgi${PHP_VER}"; \ cp "/usr/bin/php-cgi${PHP_VER}" "/usr/local/bin/php${php_tag}.cgi"; \ chmod 755 "/usr/local/bin/php${php_tag}.cgi" # Self-signed certificate, log folders and the phprc file that plays the # role of ~/.php/<version>/phprc. RUN set -eux; \ mkdir -p /etc/ssl/example.com \ /logs/example.com/http \ /logs/example.com/https \ "/var/www/.php/${PHP_VER}"; \ openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \ -subj "/CN=example.com" \ -addext "basicConstraints=critical,CA:FALSE" \ -addext "subjectAltName=DNS:example.com,DNS:www.example.com,DNS:localhost" \ -keyout /etc/ssl/example.com/key.pem \ -out /etc/ssl/example.com/cert.pem; \ printf '%s\n' \ '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' \ > "/var/www/.php/${PHP_VER}/phprc"; \ ln -s "/var/www/.php/${PHP_VER}/phprc" "/etc/php/${PHP_VER}/cgi/conf.d/99-phprc.ini"; \ chown -R www-data:www-data /logs /var/www/.php COPY apache-site.conf /etc/apache2/sites-available/dreamhost.conf.template RUN set -eux; \ php_tag="$(printf '%s' "$PHP_VER" | tr -d '.')"; \ sed -e "s/@PHP_CGI@/php${php_tag}.cgi/" \ -e "s/@PHP_VER@/${PHP_VER}/" \ /etc/apache2/sites-available/dreamhost.conf.template \ > /etc/apache2/sites-available/dreamhost.conf; \ rm /etc/apache2/sites-available/dreamhost.conf.template; \ a2ensite dreamhost RUN mkdir -p /var/www/html \ && chown www-data:www-data /var/www/html COPY entrypoint.sh /usr/local/bin/dreamhost-entrypoint RUN chmod 755 /usr/local/bin/dreamhost-entrypoint EXPOSE 80 443 ENTRYPOINT ["dreamhost-entrypoint"]

Точка входа образа, похожего на DreamHost, создаёт папки времени выполнения, которые контейнер не хранит, и запускает Apache на переднем плане:

cat container/containerfiles/hosting/dreamhost/entrypoint.sh

#!/bin/sh # /run is a tmpfs in a container, so Apache's runtime folders are created # here rather than by the Debian service script. set -e mkdir -p /var/run/apache2 /var/lock/apache2 /var/log/apache2 chown www-data:www-data /var/run/apache2 /var/lock/apache2 exec /usr/sbin/apache2ctl -D FOREGROUND

Файл compose запускает оба образа против одного и того же сайта и публикует три порта:

cat container/containerfiles/hosting/compose.yml

name: hosting-like services: beget: build: context: beget args: PHP_VER: ${PHP_VER:-8.5} image: hosting-like-beget:${PHP_VER:-8.5} ports: - "8091:80" volumes: - ${SITE_DIR:-../../src}:/var/www/html:ro dreamhost: build: context: dreamhost args: PHP_VER: ${PHP_VER:-8.5} image: hosting-like-dreamhost:${PHP_VER:-8.5} ports: - "8092:80" - "8493:443" volumes: - ${SITE_DIR:-../../src}:/var/www/html:ro

Сборка и запуск

SITE_DIR — это корень документов для проверки. Значение по умолчанию, ../../src, — корень документов этого репозитория относительно файла compose; задайте любой абсолютный путь или другой относительный путь от container/containerfiles/hosting/. Версия PHP — аргумент сборки PHP_VER, по умолчанию 8.5.

cd container/containerfiles/hosting

export SITE_DIR=/home/user/site

docker compose build

docker compose up -d

Для другой версии PHP передайте PHP_VER в ту же сборку:

PHP_VER=8.3 docker compose build

Адреса:

ОбразАдресСервер
Похожий на Begethttp://localhost:8091/nginx, Apache на 8080
Похожий на DreamHosthttp://localhost:8092/Apache
Похожий на DreamHosthttps://localhost:8493/Apache, самоподписанный сертификат

Первый запрос, с самоподписанным сертификатом, принятым только для этой проверки:

curl -sS -D - -o /dev/null http://localhost:8091/

HTTP/1.1 200 OK Server: nginx Date: Sat, 07 Nov 2026 23:37:18 GMT Content-Type: text/html; charset=UTF-8 Content-Length: 5 Connection: keep-alive X-Powered-By: PHP/8.5.11

Образ, похожий на DreamHost, по https:

curl -sS -k -D - -o /dev/null https://localhost:8493/

HTTP/1.1 200 OK Date: Sat, 07 Nov 2026 23:38:19 GMT Server: Apache Vary: Accept-Encoding Transfer-Encoding: chunked Content-Type: text/html; charset=UTF-8

Остановите и удалите оба контейнера, когда закончите:

docker compose down

План проверок

Шесть проверок покрывают проблемы, которые вскрывает переезд. Запустите их на обоих образах и сравните. Каждая проверка использует одноразовую папку в сайте, test-host/, поэтому не трогает настоящие страницы. Создайте её сначала:

mkdir -p $SITE_DIR/test-host

printf '<?php echo "ok\n";' > $SITE_DIR/test-host/index.php

Проверка 1: зонд PHP

Запишите зонд из проверки сервера в сайт как probe-7f3a.php и спросите оба образа. Ответ образа, похожего на Beget, содержит sapi=apache2handler и XFP='http'; ответ образа, похожего на DreamHost, — sapi=cgi-fcgi, XFP=NULL и HTTPS='on' по https:

curl -sS http://localhost:8091/probe-7f3a.php

HTTPS=NULL XFP='http' SERVER_SOFTWARE=Apache/2.4.68 (Debian) sapi=apache2handler

Образ, похожий на DreamHost, по https:

curl -sS -k https://localhost:8493/probe-7f3a.php

HTTPS='on' XFP=NULL SERVER_SOFTWARE=Apache sapi=cgi-fcgi

Проверка 2: строка php_flag

Положите одну директиву mod_php в тестовую папку:

printf 'php_flag display_errors off\n' > $SITE_DIR/test-host/.htaccess

curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8091/test-host/

200

Образ, похожий на Beget, отвечает 200: mod_php знает директиву. Образ, похожий на DreamHost, отвечает 500:

curl -s -k -o /dev/null -w "%{http_code}\n" https://localhost:8493/test-host/

500

Образ, похожий на DreamHost, пишет причину в свой журнал:

docker compose exec dreamhost tail -n 1 /logs/example.com/https/error.log

[Sat Nov 07 23:37:18.706368 2026] [core:alert] [pid 21:tid 21] [client 172.19.0.1:36322] /var/www/html/test-host/.htaccess: Invalid command 'php_flag', perhaps misspelled or defined by a module not included in the server configuration

Исправление — то же, что в статье Почему php_flag отвечает 500: закройте блок, перенесите настройки в файл phprc, перезапустите обработчик.

Проверка 3: редирект на https

Положите это правило из трёх строк из статьи Только Apache, TLS в Apache в корневой .htaccess:

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

Запросите обычный http-адрес у образа, похожего на DreamHost. Первый шаг — 301 на https:

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8092/

301 https://localhost:8092/

https-адрес отвечает 200 без редиректа:

curl -s -k -o /dev/null -w "%{http_code} %{redirect_url}\n" https://localhost:8493/

200

На образе, похожем на Beget, запрос уже http, и nginx присылает X-Forwarded-Proto: http, поэтому правило срабатывает:

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8091/

301 https://localhost:8091/

Читайте статус и Location, не переходите по редиректу: образы публикуют http и https на разных портах, поэтому редирект, построенный из HTTP_HOST, сохраняет порт запроса. На настоящем хостинге обе схемы используют 80 и 443, и переход завершается.

Проверка 4: редирект для существующего статического файла

Положите файл со статическим расширением в тестовую папку и добавьте редирект для его точного пути:

printf 'not a real png' > $SITE_DIR/test-host/photo.png

printf 'RewriteEngine On\nRewriteRule ^photo\.png$ /about/ [R=301,L]\n' > $SITE_DIR/test-host/.htaccess

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8091/test-host/photo.png

200

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8092/test-host/photo.png

301 http://localhost:8092/about/

На образе, похожем на Beget, ответ 200 без редиректа: nginx отдал файл, и Apache не прочитал правило. На образе, похожем на DreamHost, ответ 301, потому что файл отдаёт Apache и сначала читает .htaccess. Удалите файл и повторите:

rm $SITE_DIR/test-host/photo.png

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8091/test-host/photo.png

301 http://localhost:8091/about/

Теперь образ, похожий на Beget, передаёт запрос в Apache и тоже отвечает 301. Это первый сюрприз из статьи nginx перед Apache.

Проверка 5: папка с правами 700

Сделайте закрытую папку со страницей внутри:

mkdir -p $SITE_DIR/test-host/closed

printf '<?php echo "open\n";' > $SITE_DIR/test-host/closed/index.php

chmod 700 $SITE_DIR/test-host/closed

curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8091/test-host/closed/

403

curl -s -k -o /dev/null -w "%{http_code}\n" https://localhost:8493/test-host/closed/

403

Журнал ошибок образа, похожего на DreamHost, говорит почему:

docker compose exec dreamhost tail -n 1 /logs/example.com/https/error.log

[Sat Nov 07 23:38:19.320232 2026] [core:crit] [pid 19:tid 19] (13)Permission denied: [client 172.19.0.1:48554] AH00529: /var/www/html/test-host/closed/.htaccess pcfg_openfile: unable to check htaccess file, ensure it is readable and that '/var/www/html/test-host/closed/' is executable

Оба образа отвечают 403: корень документов смонтирован только для чтения и принадлежит вашему пользователю, Apache работает как www-data, и права 700 закрывают ему доступ. Откройте папку:

chmod 755 $SITE_DIR/test-host/closed

curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8091/test-host/closed/

200

Это третья проблема настоящего переезда: 69 папок с правами 700.

Проверка 6: правило для страниц, которое задевает файлы

Сайт хранит код в папке с именем .php, а шаблон правила для PATH_INFO подходит и к этой папке. Положите картинку в такую папку и рядом страницу:

mkdir -p $SITE_DIR/test-host/.php/img

printf 'not a real png' > $SITE_DIR/test-host/.php/img/logo.png

printf '<?php echo "page\n";' > $SITE_DIR/test-host/page.php

Замените правила тестовой папки правилом для PATH_INFO:

vi $SITE_DIR/test-host/.htaccess

RewriteEngine On # Redirect a PHP file with PATH_INFO to its clean address RewriteCond %{REQUEST_FILENAME} -f RewriteCond %{REQUEST_URI} ^(.+\.php)/.+$ RewriteRule ^ %1 [R=301,L]

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8091/test-host/.php/img/logo.png

200

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8092/test-host/.php/img/logo.png

301 http://localhost:8092/test-host/.php

На образе, похожем на Beget, файл отдал nginx, и правило не сработало. На образе, похожем на DreamHost, Apache прочитал правило и отправил картинку на папку. Теперь потребуйте имя файла перед .php в RewriteCond для REQUEST_URI:

RewriteCond %{REQUEST_URI} ^(.*[^/]\.php)/.+$

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8092/test-host/.php/img/logo.png

200

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8092/test-host/page.php/extra

301 http://localhost:8092/test-host/page.php

Картинка открывается, а настоящая страница с PATH_INFO по-прежнему уходит редиректом. Тот же случай на живом сайте: DreamHost: статические файлы проходят через .htaccess.

Уберите тестовую папку:

rm -rf $SITE_DIR/test-host

Образ, похожий на Beget

Что он копирует, шаг за шагом:

Чего он не копирует: TLS (образ только http), Passenger и Python, почту, DNS, панель управления и квоты аккаунта. Настоящий хостинг присылает X-Forwarded-Proto: https, когда посетитель использует https; образ присылает http, что и есть настоящее значение локального запроса.

Образ, похожий на DreamHost

Что он копирует:

Напишите зонд, который печатает две настройки PHP, и спросите его до изменения:

printf '<?php echo "display_errors=", var_export(ini_get("display_errors"), true), " error_reporting=", ini_get("error_reporting"), "\n";' > $SITE_DIR/ini-7f3a.php

curl -sS -k https://localhost:8493/ini-7f3a.php

display_errors='' error_reporting=-1

Измените настройку PHP в работающем контейнере, дописав строку в файл phprc, как на настоящем хостинге:

docker compose exec -u root dreamhost sh -c 'printf "display_errors = On\n" >> /var/www/.php/8.5/phprc'

Спросите снова до перезапуска обработчика: старый обработчик всё ещё работает со старыми настройками:

curl -sS -k https://localhost:8493/ini-7f3a.php

display_errors='' error_reporting=-1

Завершите обработчик. Следующий запрос запустит новый:

docker compose exec dreamhost pkill -x php85.cgi

curl -sS -k https://localhost:8493/ini-7f3a.php

display_errors='1' error_reporting=-1

Запрос, который приходит, пока обработчик убивают, может один раз ответить 500; журнал говорит End of script output before headers. Повторите его.

Чего он не копирует: настоящий сертификат, панель управления, почту, DNS, квоты, Python в стиле Passenger (DreamHost документирует Python как скрипты CGI и FastCGI; его страница поддерживаемых технологий перечисляет Passenger как неподдерживаемый) и точную сборку PHP месяца.

Автор статьи: Arthur Isaev