Force HTTPS in .htaccess, with one folder left on HTTP
Every page of a site should be served over HTTPS. Now and then one directory cannot be: a partner posts callbacks to an old endpoint without TLS, a device in the field speaks HTTP only, or an ACME client needs to answer a challenge before a certificate exists. The job is then to redirect everything to HTTPS except that one path.
The rule
In the .htaccess file of the document root:
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteCond %{HTTP:X-Forwarded-Proto} !https
RewriteCond %{REQUEST_URI} !^/customers(/|$) [NC]
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
Line by line: the engine is switched on, the request is only redirected when it did not arrive over TLS
(directly or through a proxy), the directory /customers is excluded, and everything else goes to the same
address on https. The query string is carried over automatically, because the target has no ? of its own.
Result for a site with the exception on /customers:
| Request | Answer |
|---|---|
http://example.com/ |
301 to https://example.com/ |
http://example.com/contact?ref=mail |
301 to https://example.com/contact?ref=mail |
http://example.com/customers/api.php |
200, served over HTTP |
The prefix trap
A condition like !^/customers/? looks equivalent, but RewriteCond is not anchored at the end. The pattern
matches any path that starts with /customers, so /customers-archive/ and /customersonly.html are
silently excluded from HTTPS as well. ^/customers(/|$) matches the directory and nothing else.
The same applies to the opposite mistake: !^customers without the leading slash never matches at all in
.htaccess context, because REQUEST_URI always begins with a slash. The redirect then runs for the folder
you wanted to protect.
Keep the ACME path open
If certificates are renewed with Let’s Encrypt over the http-01 challenge, the validation request must not
be redirected while the certificate is missing or expired. One more condition keeps it reachable:
RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/ [NC]
This line costs nothing and saves the renewal that fails at the worst possible moment.
HSTS cancels the exception
A site on HTTPS usually also sends this header:
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Once a browser has seen it, it rewrites every http:// request for that host to https:// before it
leaves the machine. Apache never gets the request, so no rewrite rule can make an exception. For a human
visitor the HTTP folder is then unreachable, while a server-to-server client that ignores HSTS still gets
through — a difference that is hard to debug from a support ticket.
So decide: either the whole host is HTTPS-only with HSTS, or the exception lives on a hostname of its own,
for example legacy.example.com, without the header and outside includeSubDomains.
The nginx version
There is no .htaccess in nginx; the same logic belongs in the server block that listens on port 80:
server {
listen 80;
server_name example.com;
root /var/www/example;
location ^~ /customers/ {
try_files $uri $uri/ /index.php?$query_string;
}
location / {
return 301 https://$host$request_uri;
}
}
location ^~ wins over the regular prefix match, so the exception is evaluated first.
Two checks before you call it done
Test the behaviour from the outside, not in a browser that may have cached a redirect:
curl -sI http://example.com/ | head -n 2
curl -sI http://example.com/customers/ | head -n 2
The first must answer 301 with a Location on https, the second 200.
And for search engines: content that stays available over both http and https is duplicate content. The
excluded directory should either be a machine endpoint that nobody searches for, or carry a canonical link to
the HTTPS address — otherwise the exception costs you rankings you did not intend to trade away.
Frequently asked questions
Why does my site redirect in an endless loop after adding these rules?
Because something in front of Apache terminates TLS — a load balancer, a CDN or a reverse proxy. Apache then sees plain HTTP on every request, %{HTTPS} stays off, and it redirects a request that already arrived over HTTPS. Add the condition RewriteCond %{HTTP:X-Forwarded-Proto} !https, which reads the header the proxy sets.
Should I use 301 or 302 for the redirect?
Test with R=302 and switch to R=301 when the rules behave. Browsers cache a 301 for a long time and often ignore a later change, so a wrong permanent redirect can follow you around for weeks on your own machine.
Can I keep HSTS and still have an HTTP folder?
No. Strict-Transport-Security tells the browser to upgrade every request to this host for the whole max-age, so a returning visitor never reaches the HTTP version. If a directory must stay on HTTP, put it on a separate hostname that is not covered by your HSTS header and not in the preload list.
Where do the rules go in a WordPress .htaccess?
Above the "# BEGIN WordPress" block. The WordPress rules end in a front controller that stops further processing, and anything you add inside the block is overwritten the next time permalinks are saved.