A request hits the CDN, which applies a redirect, forwards to origin, which applies its own redirect. Two hops for what should be one. Or worse — they disagree on the target, producing a loop. Almost always the result of independent teams configuring the same redirect at different layers without coordination. The fix is picking one canonical authority per redirect concern, disabling the duplicates everywhere else, and documenting the ownership so it doesn't drift again.
List every system that can emit a 301/302 in your stack:
| Layer | Where redirects live |
|---|---|
| CDN edge | Cloudflare Page Rules, Redirect Rules, Workers; Fastly VCL; AWS CloudFront Functions |
| Load balancer | AWS ALB rules, GCP LB rules, HAProxy rules |
| Web server | nginx config, Apache .htaccess, IIS web.config |
| Application | Express middleware, Django URL conf, Rails routes, framework redirect helpers |
| CMS | WordPress core canonical, SEO plugin redirects, dedicated redirect plugins |
| DNS | Domain registrar URL forwarding, "naked domain" redirects |
curl -IL -v http://example.com/affected-page 2>&1 | \ grep -E "< HTTP|< Location|< Server|< CF-Ray|< X-" # Sample output reveals each hop's source: # < HTTP/1.1 301 Moved Permanently # < Server: cloudflare ← hop 1 from Cloudflare # < Location: https://example.com/affected-page # < HTTP/2 301 # < Server: nginx ← hop 2 from origin nginx # < Location: https://www.example.com/affected-page # < HTTP/2 200 # < Server: nginx ← final 200 from origin
Server: cloudflare, CF-Ray: ...X-Served-By: cache-..., Server: ...X-Cache: ...cloudfront, Via: ... CloudFrontServer: nginxServer: ApacheX-Powered-By: Express, Server: gunicorn
Decision matrix:
| Concern | Recommended layer | Reason |
|---|---|---|
| HTTPS upgrade | CDN | Cheapest hop, doesn't reach origin |
| www canonicalisation | CDN | Same — infrastructure concern, no app logic needed |
| Trailing slash | CDN or web server | Infrastructure concern |
| Old URL → new URL (permanent moves) | CDN or dedicated plugin | Easy management UI, version-controlled list |
| A/B test routing | Application | Needs app state (user ID, cookies) |
| Login redirect | Application | Needs auth state |
| Geo-routing | CDN | CDN has user geo built-in, fastest |
# Cloudflare rule (KEEP)
# SSL/TLS → Edge Certificates → Always Use HTTPS: ON
# nginx origin (DISABLE):
# Before:
server {
listen 80;
return 301 https://$host$request_uri;
}
# After (comment or remove):
# server {
# listen 80;
# return 301 https://$host$request_uri;
# }
# Note: must still listen on port 80 if Cloudflare connects via HTTP (Flexible SSL)
# Switch Cloudflare SSL to Full/Full Strict so it connects via HTTPS instead
# Cloudflare Bulk Redirect or Page Rule (KEEP)
# example.com/* → https://www.example.com/$1 (301)
# Origin .htaccess (DISABLE):
# Before:
RewriteCond %{HTTP_HOST} ^example\.com$
RewriteRule ^(.*)$ https://www.example.com/$1 [R=301,L]
# After (remove these lines):
# (deleted)
# WordPress Redirection plugin (KEEP) # Manage redirects via WP admin UI # .htaccess (CHECK FOR CONFLICTS): # Look for Redirect or RewriteRule lines that duplicate plugin redirects # Comment out duplicates # <IfModule mod_rewrite.c> # # Redirect 301 /old-page /new-page (now managed by Redirection plugin) # </IfModule>
# Bad origin config in Flexible SSL setup
if ($scheme != "https") {
return 301 https://$host$request_uri;
}
# Origin always sees HTTP from Cloudflare → always redirects → loop
# Fix: use X-Forwarded-Proto header
if ($http_x_forwarded_proto != "https") {
return 301 https://$host$request_uri;
}
# Cloudflare sets this header to "https" when browser→Cloudflare was HTTPS
# Better fix: upgrade Cloudflare SSL to Full or Full (Strict)
# Then origin connection is also HTTPS, $scheme is correct
Cloudflare Dashboard → Rules → Page Rules / Redirect Rules / Bulk Redirects SSL/TLS → Edge Certificates → Always Use HTTPS Crypto → HSTS settings List every active rule. Compare to origin config. Identify duplicates.
grep -rE "return 30[12]|rewrite.*permanent|rewrite.*redirect" \ /etc/nginx/ /etc/nginx/sites-enabled/ /etc/nginx/conf.d/ # Each match is a potential redirect rule. Check if it's still needed.
grep -rE "Redirect |RedirectMatch |RewriteRule.*\[R" \ /var/www/ --include=".htaccess" # Some rules date back years and are forgotten about.
Once cleaned up, document so future changes don't recreate conflicts:
# redirect-ownership.md # # This site's redirect layers and what each handles: # # === Cloudflare (edge) === # - HTTPS upgrade: "Always Use HTTPS" page rule # - www canonicalisation: Bulk Redirect example.com/* → www.example.com/$1 # - Legacy URL moves: Bulk Redirect list (manually maintained, CSV in repo) # # === Origin nginx === # - Trailing slash: rewrite ^(.+[^/])$ $1/ permanent # - No HTTPS rules (handled by Cloudflare) # - No www rules (handled by Cloudflare) # # === WordPress (CMS) === # - Editorial URL changes: Redirection plugin # - No infrastructure redirects (handled at layer above) # # === Application === # - Login redirect: middleware handles authenticated routes # - A/B test routing: dynamic, based on user cookie # # DO NOT add infrastructure redirects (HTTPS, www, slash) at origin or below. # When adding new redirects, check this doc to identify the correct layer.
curl -IL http://example.com/affected-page 2>&1 | grep -cE "^HTTP" # Should be 2: one 301 + final 200 # More than 2 = chain still exists