ERR_TOO_MANY_REDIRECTS means the browser asked for a page and got a 3xx response pointing somewhere else, followed that, got another redirect, and after roughly 20 hops gave up. The loop is almost never one broken rule — it is two rules that each think the other one is wrong about the protocol, the hostname or the trailing slash. This guide shows you how to read the redirect chain one hop at a time and then find the layer that closes the circle.
Before you start
You need:
- SSH access to the VPS. If you are not connected yet, see Connect to your Linux VPS with SSH.
- Knowledge of which web server serves the site (
nginx,apache2orhttpd) and whether a proxy sits in front of it. - A way to test the headers.
curlis on every supported distro; browsers hide the intermediate hops.
Install curl if it is missing:
# Ubuntu / Debian
sudo apt update && sudo apt install -y curl
# AlmaLinux / Rocky Linux
sudo dnf install -y curlBefore you change anything, note the current configuration files you plan to edit. A copy lets you roll back in seconds if a change locks you out of the site:
sudo cp /etc/nginx/sites-available/example.com /root/example.com.bakWarning: Redirect rules sit in front of the whole site. A wrong return 301 or RewriteRule can take the site down for every visitor until you revert it. Keep the backup and test with curl before reloading the service.
Read the redirect chain
Do not guess. Ask the server what it actually returns, without following the hops, so you can see each Location: value in order.
curl -sSI http://example.com/Expected output includes something like:
HTTP/1.1 301 Moved Permanently
Server: nginx
Location: https://example.com/Now request that Location target and repeat until a hop points back to something you have already seen:
curl -sSI https://example.com/
curl -sSI https://www.example.com/Use -I for a HEAD request and the extra -S so errors still print. If a hop returns 200, the chain ends there and the loop is coming from somewhere else — usually the application setting rather than the web server.
To see the whole chain at once and confirm the browser's behaviour:
curl -sSIL --max-redirs 10 http://example.com/ -o /dev/null -w '%{url_effective}\n'The key questions are:
- Does
http://go tohttps://, and doeshttps://stay onhttps://? - Does
www.example.comgo toexample.com, and doesexample.comstay put? - Is a proxied request from Cloudflare or another proxy arriving as plain HTTP, so the "force HTTPS" rule fires again and again?
Tip: Write the hops down on paper (http://example.com → https://example.com → https://www.example.com → http://example.com). The repeated entry is your loop, and the rule that produced the second hop into it is the one to fix.
Check the web server rules
Nginx
List every place a redirect could be defined — site configs and the shared snippets:
sudo nginx -T | grep -nE 'return 30[12378]|rewrite|listen|server_name' | head -60Look for two rules that disagree. A common pattern is a return 301 https://$host$request_uri; inside the port 80 block while a separate server block also redirects www to non-www, and the $host variable is expanded before the www rewrite, so the request bounces.
A safe, single-purpose HTTP-to-HTTPS block looks like this:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://example.com$request_uri;
}Note that the target is a literal hostname, not $host — mixing $host with a www-canonicalisation rule is the usual cause of the loop. If you proxy to an application, also confirm the app sees the original scheme:
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;Without X-Forwarded-Proto, an application that forces HTTPS will redirect again even though the visitor is already on HTTPS. More patterns are in Nginx 301 redirects and rewrite rules explained and Redirect HTTP to HTTPS in Nginx.
Test and reload only after nginx -t passes:
sudo nginx -t && sudo systemctl reload nginxApache
Check the vhost and any .htaccess files in the document root and one level up:
sudo apachectl -S
sudo grep -rnE 'Redirect|RewriteRule|RewriteCond' /etc/apache2 /etc/httpd 2>/dev/nullTwo rules that fight each other usually look like this:
RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]If %{HTTPS} is off even on HTTPS requests (because TLS terminates at a proxy), this rule loops forever. Fix it by trusting the forwarded header:
RewriteCond %{HTTP:X-Forwarded-Proto} !https
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]Then reload: sudo systemctl reload apache2 (Debian/Ubuntu) or sudo systemctl reload httpd (AlmaLinux/Rocky). More detail is in Apache redirects with.htaccess: HTTPS, www and 301s.
Check the application and the proxy
WordPress
WordPress stores its own idea of the site URL, and it will redirect to it no matter what your web server says. If siteurl or home is http://example.com while the web server forces HTTPS, you get a loop.
cd /var/www/example.com
wp option get siteurl --allow-root
wp option get home --allow-rootSet both to the canonical HTTPS URL, then flush rewrite rules:
wp option update siteurl 'https://example.com' --allow-root
wp option update home 'https://example.com' --allow-root
wp rewrite flush --allow-rootAlso check wp-config.php for a hard-coded WP_HOME or WP_SITEURL that overrides the database, and look for a redirect plugin that duplicates the web server rule.
Cloudflare and other proxies
If the DNS record is proxied, Cloudflare can redirect HTTP to HTTPS on its own. When the origin also redirects, and the proxy talks to the origin over HTTP on port 80, the two fight. Pick one place for the redirect:
- Set SSL/TLS mode to Full (strict) so the edge talks to your origin over HTTPS, or
- Turn off the edge redirect and let Nginx or Apache do it.
Also confirm the proxy is not stripping or rewriting the Host header, which makes the origin send the visitor to a hostname that redirects back. Check Cloudflare's current documentation for the exact setting names, since they change.
If the site sits behind a CDN you cannot change, look at the origin access logs to see which scheme and host arrive:
sudo tail -n 50 /var/log/nginx/access.log
sudo tail -n 50 /var/log/apache2/access.logCheck it works
Run the chain again for each entry point and confirm it ends in a single 200 with no repeats:
for u in http://example.com/ http://www.example.com/ https://www.example.com/ https://example.com/; do
echo "== $u"
curl -sSIL --max-redirs 10 "$u" -o /dev/null -w 'final: %{url_effective} status: %{http_code} redirects: %{num_redirects}\n'
doneYou want one or two redirects at most — for example http://www → https://example.com in a single hop — and a final 200. Then hard-reload in a private browser window to rule out cached 301 responses; browsers cache permanent redirects aggressively.
Troubleshooting
- Loop only on
www, fine on the apex domain → the www-to-non-www rule sends traffic tohttps://example.comwhile the HTTP-to-HTTPS rule uses$hostand sends it back tohttps://www.example.com. Make the HTTPS target a fixed hostname. See Redirect www to non-www in Nginx. - Loop only behind Cloudflare or a load balancer → the origin sees HTTP and re-applies its HTTPS rule. Trust
X-Forwarded-Protoin Nginx or Apache, or move the edge to Full (strict). - Loop starts after a WordPress migration →
siteurlandhomestill hold the old scheme or hostname. Update both and flush rewrites. curl -Ireturns 200 but the browser still loops → a cached301or a service worker is replaying the chain. Clear site data for the domain and retest in a private window.- Loop on a subdirectory or a specific page → an application-level redirect (login, trailing slash, language prefix) points at a URL that the web server then rewrites back. Check the access log for the repeated path and search the app config for it.
Next steps
Once redirects behave, tidy up the rest of the web stack:
- Install Nginx and host your first website for a clean baseline configuration.
- Caddy redirects, headers, gzip/zstd compression and caching if you would rather let Caddy handle canonicalisation.
- First steps after your Linux VPS is ready to confirm firewall and update settings.
