Enforcing HTTPS

Chapter 2 — Enforcing HTTPS

Installing a certificate (Chapter 1) makes HTTPS available — but it doesn't stop users arriving over plain HTTP. A visitor who types osztromok.com without the https://, follows an old bookmark, or clicks a link from an older site will make their first request over HTTP. That first request is unencrypted, and could include cookies or credentials. Enforcing HTTPS means redirecting HTTP traffic to HTTPS before any content is served — and then using HSTS to tell browsers to never attempt HTTP again.

What this chapter covers: Why a certificate alone is not enough. Three Apache redirect methods and when to use each. Cloudflare's "Always Use HTTPS" edge setting. The redirect loop trap with Cloudflare Flexible SSL mode — and how to avoid it. HSTS header parameters (max-age, includeSubDomains, preload). Configuring HSTS in Apache with mod_headers. The HSTS preload list — commitment and consequences. Testing redirects and headers with curl. Troubleshooting ERR_TOO_MANY_REDIRECTS and missing HSTS headers.

Why a Certificate Alone Is Not Enough

Without enforcement — both paths exist: Visitor types http://osztromok.com │ ▼ HTTP request — unencrypted, credentials visible to ISP/network Apache serves the page over HTTP ← dangerous Visitor types https://osztromok.com │ ▼ HTTPS — TLS handshake, certificate verified, encrypted Apache serves the page over HTTPS ← safe With enforcement: Visitor types http://osztromok.com │ ▼ 301 Permanent Redirect → https://osztromok.com │ ▼ HTTPS — encrypted connection established Apache serves the page ← always safe

The redirect is a 301 Permanent Redirect, not a 302 Temporary. The permanent status tells browsers and search engines that HTTP is never the right choice for this domain. Search engines consolidate SEO signals to the HTTPS version, and browsers cache the redirect so repeat visitors skip the HTTP round-trip entirely.

Three Ways to Redirect HTTP to HTTPS in Apache

Method 1 — Redirect directive (simplest)
One-liner inside the *:80 VirtualHost. Clean, readable, no modules beyond what's already loaded.
  • Uses mod_alias (built in)
  • Redirects all URLs under /
  • Preserves path and query string
  • Best for: most cases — this is the recommended approach
Method 2 — mod_rewrite
More powerful — can add conditions (e.g. skip redirect for certbot challenge paths, or only redirect specific domains).
  • Requires mod_rewrite (usually already enabled)
  • Conditional redirects possible
  • Handles X-Forwarded-Proto checks
  • Best for: complex redirect logic or Cloudflare Flexible SSL mode
Method 3 — RedirectMatch
Like Redirect but uses regex — redirect only URLs matching a specific pattern, leave others alone.
  • Regex-based URL matching
  • Good for partial enforcement
  • Less common in practice
  • Best for: migrating specific paths to HTTPS before full enforcement
Cloudflare "Always Use HTTPS"
Handled at Cloudflare's edge — HTTP requests are redirected to HTTPS before they ever reach your server. Zero Apache config.
  • Cloudflare dashboard → SSL/TLS → Edge Certificates
  • Eliminates redirect loop risk entirely
  • Works in Flexible, Full, or Full Strict mode
  • Best for: simplest setup when all traffic goes through Cloudflare

Method 1 — Redirect directive

# /etc/apache2/sites-available/osztromok.com.conf # The *:80 block now only redirects — no DocumentRoot needed <VirtualHost *:80> ServerName osztromok.com ServerAlias www.osztromok.com # 301 permanent redirect — whole site, preserve path Redirect permanent / https://osztromok.com/ </VirtualHost> <VirtualHost *:443> ServerName osztromok.com ServerAlias www.osztromok.com DocumentRoot /var/www/osztromok.com/public_html <Directory /var/www/osztromok.com/public_html> Options FollowSymLinks AllowOverride All Require all granted </Directory> SSLEngine on SSLCertificateFile /etc/letsencrypt/live/osztromok.com/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/osztromok.com/privkey.pem ErrorLog ${APACHE_LOG_DIR}/osztromok_error.log CustomLog ${APACHE_LOG_DIR}/osztromok_access.log combined </VirtualHost>

Method 2 — mod_rewrite (for Flexible SSL or conditional logic)

<VirtualHost *:80> ServerName osztromok.com ServerAlias www.osztromok.com RewriteEngine On # Check if the original request (before Cloudflare) was HTTP. # X-Forwarded-Proto is set by Cloudflare to "https" when the # visitor used HTTPS — even when Cloudflare sends HTTP to Apache. RewriteCond %{HTTP:X-Forwarded-Proto} !https RewriteCond %{HTTPS} off RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] </VirtualHost>
Which method to use? If you followed Chapter 1 and switched Cloudflare to Full (Strict) SSL mode, use Method 1 — Apache receives genuine HTTPS connections from Cloudflare, and the simple Redirect permanent works without any loop risk. If you're on Flexible SSL mode (Cloudflare sends HTTP to Apache), use Method 2 with the X-Forwarded-Proto check, or better yet — use Cloudflare's "Always Use HTTPS" edge setting instead.

Cloudflare "Always Use HTTPS"

This is the cleanest solution if all traffic goes through Cloudflare. The redirect happens at Cloudflare's edge — the HTTP request never touches your server. Enable it in the Cloudflare dashboard:

SSL/TLS → Edge Certificates → Always Use HTTPS → toggle On

When enabled, a visitor who requests http://osztromok.com gets a 301 redirect to https://osztromok.com from Cloudflare's servers directly. Your Apache config doesn't need a redirect at all — the *:80 VirtualHost can simply return a 404 or be left as-is.

Use either Cloudflare "Always Use HTTPS" OR an Apache redirect — not both. Enabling both creates a situation where Cloudflare intercepts the redirect and tries again, Apache redirects again, and the cycle can confuse some clients. Pick one layer to own the redirect. Cloudflare edge is preferred; Apache redirect is the fallback for non-Cloudflare traffic.

The Redirect Loop Trap

This is the most common mistake when adding HTTPS redirects behind Cloudflare, and it produces the browser error ERR_TOO_MANY_REDIRECTS.

The loop — happens with Flexible SSL + Apache redirect: Visitor → http://osztromok.com │ ▼ Cloudflare (Flexible mode): forwards as HTTP to Apache │ Apache sees HTTP → redirects to https://osztromok.com (301) │ ▼ Cloudflare receives the redirect, fetches https://osztromok.com but forwards it as HTTP to Apache (because Flexible mode) │ Apache sees HTTP → redirects to https://osztromok.com (301) again │ ↻ Loop — browser gives up after 10 iterations: ERR_TOO_MANY_REDIRECTS ───────────────────────────────────────────────────────────────── Solutions (pick one): A. Switch Cloudflare to Full (Strict) SSL mode → Cloudflare sends HTTPS to Apache → Apache sees HTTPS → no redirect (this is what Chapter 1 set up) B. Use Cloudflare "Always Use HTTPS" instead of Apache redirect → HTTP is handled at Cloudflare edge → Apache never sees HTTP → no loop C. Use mod_rewrite with X-Forwarded-Proto check → Apache only redirects when X-Forwarded-Proto is NOT https → If Cloudflare set it to "https", Apache passes through → no loop

HSTS — HTTP Strict Transport Security

A redirect upgrades the current HTTP request to HTTPS. HSTS goes further: it tells the browser to never attempt HTTP for this domain again — for a specified duration. The browser enforces HTTPS locally, before making any network request, so there's no HTTP exposure even on the first visit after the cached instruction is set.

How HSTS works: First visit (HTTPS): Browser → https://osztromok.com Apache responds with content + header: Strict-Transport-Security: max-age=31536000; includeSubDomains Browser caches: "osztromok.com requires HTTPS for 31536000 seconds (1 year)" Any future visit within that year: User types "osztromok.com" or clicks "http://osztromok.com" Browser converts to https:// INTERNALLY, before sending any request → No HTTP request ever leaves the browser for this domain

This eliminates the "HTTPS downgrade attack" window — the brief moment during the first HTTP request before the redirect fires, when an attacker on the same network could intercept or modify the traffic.

HSTS Header Parameters

max-age=31536000
How long the browser enforces HTTPS (in seconds). 31536000 = 1 year. Start with a shorter value (e.g. max-age=86400 = 1 day) while testing, then increase to a year once you're confident HTTPS is solid. Once set to a long duration, reducing it requires waiting for all cached instructions to expire.
includeSubDomains
Apply HSTS to all subdomains. If set, the browser also enforces HTTPS for blog.osztromok.com, api.osztromok.com, etc. Only add this when every subdomain has a valid HTTPS certificate. If any subdomain has no cert, adding this will make that subdomain inaccessible in browsers that have cached the HSTS instruction.
preload
Request inclusion in the browser preload list. The HSTS preload list is hardcoded into Chrome, Firefox, Safari, and Edge — so even a first-ever visit to your domain is forced to HTTPS. Requires submitting to hstspreload.org. See the commitment note below before adding this.

Configuring HSTS in Apache

HSTS is set via the Strict-Transport-Security response header, added by mod_headers. It must be in the *:443 VirtualHost only — browsers ignore this header on plain HTTP responses (rightly so, since an attacker on HTTP could strip it).

# /etc/apache2/sites-available/osztromok.com.conf — complete with redirect + HSTS <VirtualHost *:80> ServerName osztromok.com ServerAlias www.osztromok.com # Redirect everything to HTTPS permanently Redirect permanent / https://osztromok.com/ </VirtualHost> <VirtualHost *:443> ServerName osztromok.com ServerAlias www.osztromok.com DocumentRoot /var/www/osztromok.com/public_html <Directory /var/www/osztromok.com/public_html> Options FollowSymLinks AllowOverride All Require all granted </Directory> SSLEngine on SSLCertificateFile /etc/letsencrypt/live/osztromok.com/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/osztromok.com/privkey.pem # ── HSTS — tell browsers to always use HTTPS ───────────────── # Start with max-age=86400 (1 day) while testing. # Increase to 31536000 (1 year) once HTTPS is confirmed stable. # Add includeSubDomains only when ALL subdomains have valid certs. Header always set Strict-Transport-Security "max-age=86400" ErrorLog ${APACHE_LOG_DIR}/osztromok_error.log CustomLog ${APACHE_LOG_DIR}/osztromok_access.log combined </VirtualHost>
# Verify mod_headers is loaded (enabled in Chapter 1) $ sudo apache2ctl -M | grep headers headers_module (shared) ← must be present # If not loaded: $ sudo a2enmod headers && sudo systemctl restart apache2 # Validate config, then reload $ sudo apache2ctl configtest && sudo systemctl reload apache2

Progressively hardening HSTS

Don't jump straight to a 1-year HSTS with preload — if anything breaks, you're locked in for a very long time. Work through this progression over weeks:

StageHeader valueWhen to move to next stage
Testingmax-age=86400 (1 day)HTTPS stable, no cert issues after 1 week
Moderatemax-age=604800 (1 week)All subdomains have certs; no issues after 2 weeks
Productionmax-age=31536000; includeSubDomainsConfident everything will stay on HTTPS long-term
Preload-readymax-age=31536000; includeSubDomains; preloadSubmit to hstspreload.org — permanent commitment

HSTS Preload List

The HSTS preload list is a database maintained by Google (and used by Chrome, Firefox, Safari, Edge) that hardcodes domains as HTTPS-only directly in the browser binary. Even a brand new browser that has never visited your site will refuse to make HTTP requests to a preloaded domain.

Before submitting to the preload list — understand the commitment
Preloading is effectively permanent. Once your domain is in the list and shipped in browser releases:
  • Removal takes 6–12 months minimum — browsers don't update daily, and cached binaries persist.
  • Every subdomain of osztromok.com must serve HTTPS — no exceptions. Any subdomain without a valid cert becomes completely inaccessible in browsers that have cached the preload entry.
  • You cannot run anything over plain HTTP on the domain — ever. Not even temporary test pages, admin panels, or internal tools.
  • If your cert expires and renewal fails, your entire domain goes dark until it's fixed — browsers won't even show a "proceed anyway" option.
For a personal/learning site: production-level HSTS (max-age=31536000; includeSubDomains) is sufficient. Preload is for large-scale, mission-critical deployments where even first-visit HTTPS matters.

Complete Setup Walkthrough

Full Walkthrough · HTTPS Enforcement + HSTS
Enable Cloudflare "Always Use HTTPS", configure Apache redirect as a defence-in-depth fallback, and add HSTS — starting conservative and verifying at each step.
1
Enable "Always Use HTTPS" in Cloudflare (primary enforcement). Go to SSL/TLS → Edge Certificates → Always Use HTTPS → toggle On. This handles the redirect at Cloudflare's edge — the most reliable layer since Cloudflare processes all traffic before it reaches your server.
2
Confirm Cloudflare SSL mode is Full (Strict) (from Chapter 1). Go to SSL/TLS → Overview → Full (Strict). This ensures that when Cloudflare contacts your Apache server (via the tunnel), it uses HTTPS and verifies your Let's Encrypt certificate — so the redirect in Apache won't loop.
3
Update the Apache *:80 VirtualHost to redirect (defence in depth). Edit /etc/apache2/sites-available/osztromok.com.conf. Replace the *:80 block content with:
ServerName osztromok.com ServerAlias www.osztromok.com Redirect permanent / https://osztromok.com/
This ensures any direct HTTP connection to Apache (bypassing Cloudflare) is still redirected.
4
Add a conservative HSTS header to the *:443 VirtualHost. Inside the <VirtualHost *:443> block, add:
Header always set Strict-Transport-Security "max-age=86400"
Starting with 1 day — easy to recover from if anything breaks.
5
Validate config and reload Apache.
$ sudo apache2ctl configtest Syntax OK $ sudo systemctl reload apache2
6
Test the redirect and verify the HSTS header.
# Test redirect locally (bypassing Cloudflare) $ curl -I -H "Host: osztromok.com" http://localhost HTTP/1.1 301 Moved Permanently Location: https://osztromok.com/ # Test HSTS header is present on HTTPS response $ curl -sk -H "Host: osztromok.com" https://localhost -o /dev/null -D - | grep -i strict Strict-Transport-Security: max-age=86400 # From outside — follow redirects and show final URL $ curl -sIL http://osztromok.com | grep -E "HTTP|Location|Strict" HTTP/1.1 301 Moved Permanently Location: https://osztromok.com/ HTTP/2 200 strict-transport-security: max-age=86400
7
After 1 week with no issues, increase max-age and add includeSubDomains. Edit the HSTS header in the Apache config:
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Only add includeSubDomains when all your subdomains have valid HTTPS certificates. Reload Apache after the change.
HSTS takes effect from the first HTTPS response the browser sees. Once a browser has cached it, even clearing browser history won't remove the HSTS policy — the user would need to go to chrome://net-internals/#hsts (Chrome) and delete the entry manually. This is by design.

X-Forwarded-Proto — Reading the Real Protocol

When traffic passes through a proxy (Cloudflare, a load balancer, another reverse proxy), the proxy adds an X-Forwarded-Proto header to tell the backend what protocol the original client used — even if the proxy changed it en route.

Cloudflare Flexible SSL mode — what Apache sees vs what the visitor did: Visitor → HTTPS → Cloudflare → HTTP → Apache Apache sees: request over HTTP But: X-Forwarded-Proto: https ← Cloudflare adds this Visitor → HTTP → Cloudflare → HTTP → Apache Apache sees: request over HTTP And: X-Forwarded-Proto: http ← no HTTPS involved mod_rewrite can use this to redirect only genuine HTTP visits: RewriteCond %{HTTP:X-Forwarded-Proto} =http → redirect to HTTPS RewriteCond %{HTTP:X-Forwarded-Proto} =https → pass through (already HTTPS)

This is why the mod_rewrite method (Method 2) works correctly with Flexible SSL — it checks X-Forwarded-Proto before deciding whether to redirect. The simple Redirect permanent doesn't do this check, which is why it causes loops in Flexible mode.

Only trust X-Forwarded-Proto from sources you control. Any client can add a fake X-Forwarded-Proto: https header to bypass your redirect if Apache accepts it from arbitrary sources. Behind Cloudflare, this is mitigated because Cloudflare strips or overwrites the header before forwarding. In general, only rely on proxy headers from known, trusted proxies.

Troubleshooting

ERR_TOO_MANY_REDIRECTS — browser can't load the page at all
Classic redirect loop. Check: (1) Is Cloudflare SSL mode set to Flexible? If so, Apache receives HTTP from Cloudflare, and a simple Redirect permanent sends it back to HTTPS — which Cloudflare again sends as HTTP to Apache. Fix: switch to Full (Strict) SSL mode (Chapter 1) or use mod_rewrite with X-Forwarded-Proto check. (2) Is "Always Use HTTPS" enabled in Cloudflare AND you have an Apache redirect? Disable one. (3) Test without Cloudflare: curl -I -H "Host: osztromok.com" http://localhost — if this also loops, the issue is in Apache alone (check for duplicate redirect rules).
HSTS header not appearing in the response
Three common causes: (1) mod_headers not loaded — check with sudo apache2ctl -M | grep headers. If missing, run sudo a2enmod headers && sudo systemctl restart apache2. (2) Header is in the *:80 block instead of *:443 — browsers ignore HSTS on HTTP responses. (3) Apache was reloaded (not restarted) after enabling a module — modules require restart, not just reload. Check: curl -sk https://localhost -D - -o /dev/null | grep -i strict.
Redirect works but drops the path — /blog/post-1 becomes just /
The Redirect permanent / directive preserves the path — http://osztromok.com/blog/post-1 redirects to https://osztromok.com/blog/post-1. If the path is being dropped, check for a secondary redirect somewhere (Cloudflare Page Rules, a .htaccess RewriteRule, or a duplicate Redirect directive pointing at a specific path). Run curl -sIL http://osztromok.com/blog/post-1 and examine each redirect in the chain.
Browser shows "Not Secure" on some pages even after enabling HTTPS
Mixed content — the page loads over HTTPS but references resources (images, scripts, stylesheets) via plain http:// URLs. The page itself is encrypted but those resources are not, so the browser downgrades the security indicator. Fix: update all asset URLs to use https:// or protocol-relative //. Apache can help with mod_substitute or a blanket Content-Security-Policy upgrade (covered in Chapter 6).
HSTS is set but a subdomain is now inaccessible
You added includeSubDomains before the subdomain had a valid HTTPS certificate. The browser is enforcing HTTPS on the subdomain and the cert is missing or invalid. Fix: immediately get a certificate for the subdomain (certbot with -d subdomain.osztromok.com) and configure Apache for HTTPS on that subdomain. To test the fix, clear HSTS for the domain: in Chrome, visit chrome://net-internals/#hsts, enter the subdomain under "Delete domain security policies," then retest.

Quick Reference — Chapter 2

Command / CheckPurpose
curl -I -H "Host: osztromok.com" http://localhostTest redirect locally — should return 301 with Location: https://
curl -sIL http://osztromok.com | grep -E "HTTP|Location|Strict"Follow the full redirect chain and check for HSTS in the final response
curl -sk https://localhost -D - -o /dev/null | grep -i strictVerify HSTS header is in the HTTPS response from Apache directly
sudo apache2ctl -M | grep headersConfirm mod_headers is loaded (required for HSTS)
sudo a2enmod headers && sudo systemctl restart apache2Enable mod_headers — restart required (not just reload)
chrome://net-internals/#hstsInspect or delete HSTS entries in Chrome — useful for testing
HSTS valueMeaningWhen to use
max-age=86400Enforce HTTPS for 1 dayTesting phase — easy to recover
max-age=604800Enforce HTTPS for 1 weekEarly production — gaining confidence
max-age=31536000Enforce HTTPS for 1 yearStable production — HTTPS confirmed solid
; includeSubDomainsApply rule to all subdomainsOnly when all subdomains have valid certs
; preloadRequest browser preload inclusionAfter submitting to hstspreload.org — permanent
ApproachBest forRedirect loop risk?
Cloudflare "Always Use HTTPS"All traffic through CloudflareNone — handled at edge before Apache
Apache Redirect permanent (Method 1)Full (Strict) SSL modeNone — Apache receives genuine HTTPS
mod_rewrite + X-Forwarded-Proto (Method 2)Flexible SSL mode or complex logicNone — checks the original protocol first
Apache Redirect + Flexible SSL (wrong combo)AvoidYes — ERR_TOO_MANY_REDIRECTS