Redirects behind an HTTPS proxy

The extra http hop

An update from October 2026. A check of this site (aredel.com) showed that 1,358 of the 1,362 redirects in the root .htaccess sent visitors from https to http, and only then back to https. All of them were Redirect or RedirectMatch lines like this one:

Redirect 301 /user_profile/ /services/user_profile/

The first hop, asked for as in Testing rules:

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

301 http://example.com/services/user_profile/

The browser follows it with a plain HTTP request, and the HTTPS rule at the top of the file sends it back:

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://example.com/services/user_profile/

301 https://example.com/services/user_profile/

Two hops instead of one, and the middle request is unencrypted: its URL, and any cookie not marked Secure, can be read on the way. The same mechanics are behind the choice in Redirect 301 or the pair.

Why Apache writes http://

The old hosting ended HTTPS at its front server (nginx) and passed the request on to Apache over plain HTTP. That host is described in Beget shared hosting. mod_alias builds the absolute Location from the scheme Apache itself sees, and that scheme is http. The line SetEnvIf X-Forwarded-Proto https HTTPS=on at the top of the file does not help: it sets a variable for PHP, not the scheme of the server.

Two more redirects had the same flaw. mod_dir adds the slash to a folder asked for without one (/qa to /qa/) with the same http scheme. The rule that strips PATH_INFO from PHP URLs had the bare path %1 as its target, and mod_rewrite puts its own http scheme in front of a bare path.

What did not work: editing the Location header

The shortest idea is to fix the header on its way out:

Header always edit Location ^http:// https://

In a local test it changed nothing. A test header added with Header always set did reach the 301 answer, so the headers module runs for redirects, but the Location of a mod_alias redirect stayed http. The redirects had to build the right URL themselves.

The fix: every redirect is a mod_rewrite pair

In mod_rewrite the target carries its own scheme. The rewrite block now opens the file and first picks the scheme for every redirect below it: http for the local container, which has no TLS, and https for every other host.

vi .htaccess

<IfModule mod_rewrite.c> RewriteEngine On RewriteCond %{HTTP_HOST} ^(?:localhost|127\.0\.0\.1|php-apache)(?::\d+)?$ RewriteRule ^ - [E=AREDEL_SCHEME:http] RewriteCond %{HTTP_HOST} !^(?:localhost|127\.0\.0\.1|php-apache)(?::\d+)?$ RewriteRule ^ - [E=AREDEL_SCHEME:https] # Redirect non-HTTPS requests (the condition added on DreamHost is below) RewriteCond %{HTTP:X-Forwarded-Proto} !https RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] # Redirect any PHP file that has PATH_INFO to its clean version; # [^/]: a file name before .php, never a folder named .php RewriteCond %{REQUEST_FILENAME} -f RewriteCond %{REQUEST_URI} ^(.*[^/]\.php)/.+$ RewriteRule ^ %{ENV:AREDEL_SCHEME}://%{HTTP_HOST}%1 [R=301,L] </IfModule>

The first version of the PATH_INFO pattern was ^(.+\.php)/.+$. It also matched a folder named .php, so every image and script in that folder was redirected to the folder. On Beget nobody noticed, because nginx serves existing files before Apache reads the rules. On DreamHost, Apache serves them: DreamHost: static files go through .htaccess.

Every old line became a pair: a RewriteCond that tests the full URL path, and a RewriteRule that sends the redirect. The redirect from the start of this chapter now reads:

RewriteCond %{REQUEST_URI} ^/user_profile/(.*)$ RewriteRule ^ %{ENV:AREDEL_SCHEME}://%{HTTP_HOST}/services/user_profile/%1 [R=301,L]

The test is on %{REQUEST_URI} rather than in the RewriteRule pattern, because REQUEST_URI keeps the leading slash and the folder: the regex reads the same in the root file and in a subfolder file (the RewriteRule pattern loses both, see RewriteRule basics). Groups captured by a RewriteCond come back as %1, not $1. How each kind of old line was converted:

Old lineRewriteCond patternRewriteRule target
Redirect 301 /hw/disk_b/ /ru/hw/disk_b/^/hw/disk_b/(.*)$/ru/hw/disk_b/%1
Redirect 301 /debian/date_time.php /ru/linux/debian/date_time.php^/debian/date_time\.php(/.*)?$/ru/linux/debian/date_time.php%1
RedirectMatch 301 ^/robot/?$ /qa/robot/^/robot/?$/qa/robot/
RedirectMatch 404 ^/services/user_profile/data/^/services/user_profile/data/- [R=404,L]

A plain Redirect matches whole path segments and keeps the rest of the path, hence (.*)$ after a folder and (/.*)?$ after a file. Every target other than the 404 gets the %{ENV:AREDEL_SCHEME}://%{HTTP_HOST} prefix and the [R=301,L] flags. The order of rules is kept: the pairs run top to bottom and L stops at the first match, just as the first matching Redirect line won before.

Move to DreamHost: the loop

The site moved to DreamHost, where TLS ends in Apache itself and no proxy sends X-Forwarded-Proto. The HTTPS rule written for the old proxy was still in the file, and every address answered 301 to 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 probe from the server check shows why:

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

%{HTTPS} is on, but the old condition only asked whether the proxy header was not https. With no header at all, "not https" held for every request, the https ones included. The PHP process is FastCGI as well:

pgrep -l -u user php

2745746 php85.cgi

ls /usr/sbin/apache2*

/usr/sbin/apache2 /usr/sbin/apache2ctl

Two conditions now have to hold together:

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

On DreamHost %{HTTPS} is on for an https request, so the first condition is false and the rule is skipped. Behind the old proxy %{HTTPS} is always off, and the header decides. The loop is gone, and a plain http request still makes one hop to https.

The same move exposed the second problem: the root file still had php_flag and php_value lines, which need mod_php. DreamHost runs PHP as FastCGI, so Apache stopped on the first one and answered 500 for every page:

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

HTTP/2 500 content-length: 533 content-type: text/html; charset=iso-8859-1 server: Apache

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

That block is now inside <IfModule mod_php.c>, so a FastCGI host skips it; the same settings live in ~/.php/<version>/phprc on DreamHost. After both changes the site answers again:

curl -sSI https://example.com/

HTTP/2 200 content-type: text/html; charset=UTF-8 server: Apache

The guards and the phprc path are spelled out in Safe rules, guards and rollback; the full case is in 500 on every page after a host move.

Subfolders with their own .htaccess

The first comparison run (see the check below) found 154 changed answers, under /ao/ and /ru/qa/. Both folders had their own .htaccess with RewriteEngine On. mod_alias lines from parent folders add up, mod_rewrite rules do not: the rules of the nearest file that has any replace the parent ones. The root rules for paths inside those folders stopped working the moment they became RewriteRules.

One line in each subfolder file brings them back. The inherited root rules run after the folder own rules:

vi ao/.htaccess

RewriteEngine On RewriteOptions Inherit

The Redirect lines of these two files were converted the same way, and each file got its own copy of the scheme block. Later the same day both files were removed, see One .htaccess instead of three.

Folders without a trailing slash

After that, 8 answers still pointed to http://: folders asked for without the slash, left to mod_dir. The last rule of the root file now adds the slash itself, with the right scheme:

vi .htaccess

RewriteCond %{REQUEST_FILENAME} -d RewriteCond %{REQUEST_URI} !/$ RewriteRule ^ %{ENV:AREDEL_SCHEME}://%{HTTP_HOST}%{REQUEST_URI}/ [R=301,L]

It stays last, so every explicit redirect above it wins. It covers every folder of the site, /ao/ and /ru/qa/ included.

How the change was checked

A script generated sample URLs for every rule of the three files, added every 150th page of the sitemap and a few control URLs, and sent each one to the local container twice: with the local host and with Host: www.example.com. Status and Location were recorded with the old files and with the new ones.

Then the three files went to the dev copy, testsetup.site, and each request below was checked like this one:

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" https://testsetup.site/user_profile/

RequestExpectedActual
https://testsetup.site/user_profile/301, one hop to https301 https://testsetup.site/services/user_profile/
https://testsetup.site/qa301, slash added, https301 https://testsetup.site/qa/
https://testsetup.site/ao/edu/mathematics/mathematics.php301 from ao/.htaccess, https301 https://testsetup.site/ao/edu/mathematics/
https://testsetup.site/ru/qa/allure301, slash added by the inherited rule301 https://testsetup.site/ru/qa/allure/
https://testsetup.site/dev/php/htaccess.php/extra301, PATH_INFO removed, https301 https://testsetup.site/dev/php/htaccess.php
http://testsetup.site/ai/301 to https301 https://testsetup.site/ai/
a file in /services/user_profile/data/404404
/, /ai/, /ao/, /ru/qa/, /services/user_profile/200200

One .htaccess instead of three

A closer look at the two subfolder files showed that almost nothing in them could still fire. Their rules were written for the old andreyolegovich.ru and testsetup.ru sites, where these folders were the document root, so they test paths like /PC/... that never occur under /ao/:

FileRulesRules that can match a URL in the folder
ao/.htaccess7191
ru/qa/.htaccess310

Their other lines repeated the root file, except two wrong ones: error_log pointed to the folders of the old sites, and ErrorDocument 404 answered a missing page with a 302 to the 404 page of the old domain. The one live rule moved to the root file, and both files were deleted, together with an empty ru/qa/lessons/.htaccess. RewriteOptions Inherit is no longer needed. The .htaccess files left below the root hold no rewrite rules: PHP session settings of the session demos and the Passenger switch of a Python app.

The same local comparison as in the check, before and after: 6026 of 6028 URLs gave the same answer. The other 2 were the control URLs /ao/no-such-page/ and /ru/qa/no-such-page/: a 302 to the old domain before, a 404 now. All 561 sitemap pages under /ao/ and /ru/qa/ still load.

Adding a redirect now

New redirects on this site are pairs, never Redirect lines. A page that moved:

vi .htaccess

# /old/page.php moved to /new/page/ RewriteCond %{REQUEST_URI} ^/old/page\.php$ RewriteRule ^ %{ENV:AREDEL_SCHEME}://%{HTTP_HOST}/new/page/ [R=301,L]

The pre-commit hook still refuses a new page whose URL is the source of a redirect. It reads both forms through container/tools/htaccess_redirects.py, which answers the same question by hand (exit code 1 when a path is redirected):

python3 container/tools/htaccess_redirects.py match container/src/.htaccess /user_profile/

/user_profile/ line 30 301 ^/user_profile/(.*)$

The checks of this chapter are a test now. It asks for each URL without following the redirect and compares the status and the exact Location, so an http:// Location on an https site fails. Besides hand-picked URLs it asks for every 25th redirect rule of the file:

cd container

BASE_URL=https://testsetup.site ./tools/run-tests-venv.sh -- tests/test_redirects.py -q

The move that produced this chapter, with the complete list of symptoms and fixes: from Beget to DreamHost.

Article author: Andrei Olegovich