Test your site on a Beget-like or DreamHost-like server
The repository has two Docker images that copy the web layer of two shared hosts: Beget and DreamHost. Mount your document root into one of them and the same code, the same .htaccess and the same file modes meet a server that behaves like the real one. Tools used here: Docker, curl, SSH.
What the images copy
Both images mount the site read-only at /var/www/html, owned by a user other than Apache. A folder with mode 700 therefore fails inside the container exactly as it fails on the host.
| Thing | Beget-like | DreamHost-like |
|---|---|---|
| Front server | nginx on port 80, Apache on 8080 | Apache alone |
| Static files on disk | served by nginx, never reach Apache | served by Apache |
| PHP mode | PHP as mod_php, sapi=apache2handler | mod_fcgid and php85.cgi, sapi=cgi-fcgi |
| php_flag | works | unknown directive, every request 500 |
| TLS | none, http only | self-signed certificate at build time |
| X-Forwarded-Proto | set by nginx | absent, HTTPS=on |
| .htaccess | AllowOverride All | AllowOverride All |
| PHP settings | in .htaccess | /var/www/.php/8.5/phprc |
| Logs | Apache log inside the container | /logs/example.com/http(s)/ |
What the images cannot copy: the control panel, mail, DNS, quotas, the real IP addresses, Passenger for Python on Beget, PHP extensions beyond the defaults (a MySQL driver, for example), and the exact package versions of a particular month. They test the web layer, not the account.
The files
Everything lives in 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
The Beget-like front server:
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; } }
The Apache side of the same image:
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>
The entrypoint starts Apache and keeps nginx in the foreground:
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;'
The Beget-like Dockerfile:
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"]
The DreamHost-like Apache, which has no mod_php and adds the FastCGI handler:
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>
The DreamHost-like Dockerfile:
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"]
The DreamHost-like entrypoint creates the runtime folders that a container does not keep and starts Apache in the foreground:
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
The compose file runs both images against the same site and publishes the three ports:
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
Build and run
SITE_DIR is the document root to test. The default, ../../src, is the docroot of this repository relative to the compose file; set it to any absolute path, or to another relative path from container/containerfiles/hosting/. The PHP version is the build argument PHP_VER, 8.5 by default.
cd container/containerfiles/hosting
export SITE_DIR=/home/user/site
docker compose build
docker compose up -d
For another PHP version, pass PHP_VER to the same build:
PHP_VER=8.3 docker compose build
The addresses:
| Image | Address | Server |
|---|---|---|
| Beget-like | http://localhost:8091/ | nginx, Apache on 8080 |
| DreamHost-like | http://localhost:8092/ | Apache |
| DreamHost-like | https://localhost:8493/ | Apache, self-signed certificate |
The first request, with the self-signed certificate accepted only for this check:
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
The DreamHost-like image over 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
Stop and remove both containers when done:
docker compose down
Test plan
Six checks cover the problems that a host move exposes. Run them against both images and compare. Every check uses a throw-away folder in the site, test-host/, so it touches no real page. Create it first:
mkdir -p $SITE_DIR/test-host
printf '<?php echo "ok\n";' > $SITE_DIR/test-host/index.php
Test 1: the PHP probe
Write the probe from the server check into the site as probe-7f3a.php and ask both images. The Beget-like answer has sapi=apache2handler and XFP='http'; the DreamHost-like answer has sapi=cgi-fcgi, XFP=NULL and HTTPS='on' over https:
curl -sS http://localhost:8091/probe-7f3a.php
HTTPS=NULL XFP='http' SERVER_SOFTWARE=Apache/2.4.68 (Debian) sapi=apache2handler
The DreamHost-like image over https:
curl -sS -k https://localhost:8493/probe-7f3a.php
HTTPS='on' XFP=NULL SERVER_SOFTWARE=Apache sapi=cgi-fcgi
Test 2: a php_flag line
Put one mod_php directive in the test folder:
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
The Beget-like image answers 200: mod_php knows the directive. The DreamHost-like image answers 500:
curl -s -k -o /dev/null -w "%{http_code}\n" https://localhost:8493/test-host/
500
The DreamHost-like image writes the reason to its log:
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
The fix is the one in Why php_flag answers 500: guard the block, move the settings to the phprc file, restart the worker.
Test 3: the https redirect
Put this three-line rule from Apache alone, TLS in Apache in the root .htaccess:
RewriteCond %{HTTPS} off RewriteCond %{HTTP:X-Forwarded-Proto} !https RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
Ask for a plain http address on the DreamHost-like image. The first hop is 301 to https:
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8092/
301 https://localhost:8092/
The https address answers 200 with no redirect:
curl -s -k -o /dev/null -w "%{http_code} %{redirect_url}\n" https://localhost:8493/
200
On the Beget-like image the request is already http and nginx sends X-Forwarded-Proto: http, so the rule fires:
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8091/
301 https://localhost:8091/
Read the status and the Location, do not follow the hop: the images publish http and https on different ports, so a redirect built from HTTP_HOST keeps the port of the request. On the real host both schemes use 80 and 443 and the hop completes.
Test 4: a redirect for a static file that exists
Put a file with a static extension in the test folder and add a redirect for its exact path:
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/
On the Beget-like image the answer is 200 with no redirect: nginx served the file and Apache never read the rule. On the DreamHost-like image the answer is 301, because Apache serves the file and reads .htaccess first. Remove the file and repeat:
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/
The Beget-like image now passes the request to Apache and also answers 301. This is the first surprise in nginx in front of Apache.
Test 5: a folder with mode 700
Make a closed folder with a page inside:
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
The DreamHost-like error log says why:
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
Both images answer 403: the document root is mounted read-only and owned by your user, Apache runs as www-data, and mode 700 shuts it out. Open the folder:
chmod 755 $SITE_DIR/test-host/closed
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8091/test-host/closed/
200
This is the third problem of the real move: 69 folders with mode 700.
Test 6: a rule for pages that also catches files
The site keeps code in a folder named .php, and a PATH_INFO rule has a pattern that also matches that folder. Put an image in such a folder, and add a page next to it:
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
Replace the rules of the test folder with the PATH_INFO rule:
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
On the Beget-like image nginx served the file, so the rule never ran. On the DreamHost-like image Apache read the rule and redirected the image to the folder. Now require a file name before .php in the RewriteCond for 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
The image loads, and a real page with PATH_INFO is still redirected. The case from a live site: DreamHost: static files go through .htaccess.
Clean up the test folder:
rm -rf $SITE_DIR/test-host
The Beget-like image
What it copies, step by step:
- nginx on port 80 answers first and serves an existing static file from disk itself. The file types are the ones listed in the nginx configuration.
- Every other request goes to Apache 2.4 on port 8080 with X-Forwarded-Proto set from the request scheme.
- PHP is mod_php (sapi=apache2handler), so php_flag and php_value work.
- AllowOverride All for the document root.
- The document root is read-only and owned by you, not by Apache.
What it does not copy: TLS (the image is http only), Passenger and Python, mail, DNS, the control panel and the account quotas. The real host sends X-Forwarded-Proto: https when the visitor uses https; the image sends http, which is the value a local request really has.
The DreamHost-like image
What it copies:
- Apache alone on ports 80 and 443, with a self-signed certificate made at build time. %{HTTPS} is on and no proxy header is present.
- PHP through mod_fcgid and a worker named php85.cgi for PHP 8.5, so sapi=cgi-fcgi.
- No mod_php: a php_flag line answers 500 for the folder and below it.
- /var/www/.php/8.5/phprc for PHP settings and /logs/example.com/http/ and /logs/example.com/https/ for the logs.
- The document root is read-only and owned by you, not by Apache.
Write a probe that prints two PHP settings, and ask for it before the change:
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
Change a PHP setting in the running container by appending to the phprc file, as on the real host:
docker compose exec -u root dreamhost sh -c 'printf "display_errors = On\n" >> /var/www/.php/8.5/phprc'
Ask again before the worker is restarted: the old worker still runs with the old settings:
curl -sS -k https://localhost:8493/ini-7f3a.php
display_errors='' error_reporting=-1
End the worker. The next request starts a new one:
docker compose exec dreamhost pkill -x php85.cgi
curl -sS -k https://localhost:8493/ini-7f3a.php
display_errors='1' error_reporting=-1
The request that arrives while the worker is being killed can answer 500 once; the log says End of script output before headers. Repeat it.
What it does not copy: the real certificate, the control panel, mail, DNS, quotas, Passenger-style Python (DreamHost documents Python as CGI and FastCGI scripts; its supported technologies page lists Passenger as not supported), and the exact PHP build of the month.
Article author: Arthur Isaev