Emergency server help: get in touch

Redirect Chain Checker

A redirect chain checker for the command line: follows each URL hop by hop with curl and flags loops, chains longer than two, 302s, HTTPS downgrades, non-200 endings and inconsistent www/https variants.

Version
1.0.0
Last updated
October 6, 2026
Language
Bash
Tested on
AlmaLinux 9.8 with DirectAdmin 1.712 (lab test, 6 Oct 2026); AlmaLinux 9.8 with cPanel & WHM 11.138 (lab test, 5 Oct 2026); Ubuntu 24.04 (bash 5.2, curl 8.5) against a local test server with 301/302/308, loops, missing Location and 404/500 endings, and live public sites
License
MIT
Pricing
Free

After a migration, an HTTPS switch or a permalink change, redirects pile up. The old .htaccess rule sends http:// to https://, WordPress sends non-www to www, a plugin adds a trailing slash, and a page that used to load in one request now takes four, one of them a temporary 302. Browsers hide all of it. This redirect chain checker shows every hop, with the status code, and tells you what is wrong with the chain.

It uses curl without -L, so it can stop and print each hop instead of jumping to the end, and it checks the four versions of a domain that visitors and search engines actually type: http and https, with and without www.

What the redirect chain checker flags

  • Loops: a URL that redirects back to one already in the chain (FAIL).
  • Too many hops: more than --max-hops redirects (FAIL).
  • Long chains: more than two redirects before the final page (WARN).
  • Temporary redirects: 302, 303 or 307 in the chain where a permanent 301 or 308 is usually meant (WARN).
  • HTTPS downgrades: an https URL redirecting to http, and chains that end on plain http (WARN).
  • Bad endings: a final status other than 200, a 3xx without a Location header, or a request that failed (DNS, TLS, timeout) with curl’s own error message (FAIL).
  • Variants (--variants): http://, http://www., https:// and https://www. of each domain must all end on the same URL (FAIL if not).

Usage

curl -fsSL https://srvscripts.com/get/redirect-tester/ -o redirect-tester.sh
bash redirect-tester.sh http://example.com/old-page https://example.com/blog
bash redirect-tester.sh --variants example.com           # the four canonical variants
bash redirect-tester.sh --variants example.com/shop/     # same, for a path
bash redirect-tester.sh --file urls.txt -q               # only URLs with problems
bash redirect-tester.sh --user-agent "Mozilla/5.0" --timeout=20 https://example.com/

urls.txt holds one URL or domain per line; # comments and blank lines are ignored. A bare domain is tested as http://domain/.

Sample output

== http://www.example.com/old-page ==
   1  301  http://www.example.com/old-page -> https://www.example.com/old-page
   2  301  https://www.example.com/old-page -> https://example.com/old-page
   3  302  https://example.com/old-page -> https://example.com/new-page/
   4  200  https://example.com/new-page/
  WARN  chain of 3 redirects (more than 2 costs time and crawl budget)
  WARN  1 temporary redirect(s) (302/303/307); a permanent move should be 301 or 308

== Variants of example.com/ ==
  http://example.com/                      -> 200 https://example.com/
  http://www.example.com/                  -> 200 https://example.com/
  https://example.com/                     -> 200 https://example.com/
  https://www.example.com/                 -> 200 https://www.example.com/
  FAIL  the four variants end on 2 different URLs; pick one canonical URL and 301 the rest to it

The old page takes three hops, because the https rule, the www rule and the page redirect run one after another; the last one is a 302 left by a redirect plugin. Changing the plugin rule to 301 and pointing it straight at the final URL fixes both warnings. The variants block shows https://www. serving the site itself instead of redirecting, so search engines see two copies of the home page.

Tested on a real server

We ran this script on our lab server on 5 October 2026: AlmaLinux 9.8 with cPanel & WHM 11.138, MariaDB 10.11 and three WordPress test accounts. The screenshot is the real terminal output; only IP addresses are masked.

Terminal output of bash redirect-tester.sh --variants srvscripts.com on AlmaLinux 9.8 with cPanel and WHM 11.138
bash redirect-tester.sh --variants srvscripts.com — exit code 0, 3.6 s. AlmaLinux 9.8, cPanel & WHM 11.138, 5 Oct 2026. IP addresses masked.

We also ran it on our DirectAdmin test server (DirectAdmin 1.712 on AlmaLinux 9.8, 6 October 2026) against a new WordPress domain. Out of the box DirectAdmin serves both HTTP and HTTPS, so the script reports three problems; after switching on Force SSL with https redirect for the domain, all four variants end on one HTTPS address.

Terminal output of redirect-tester.sh on DirectAdmin before Force SSL
Before: bash redirect-tester.sh –variants on a new DirectAdmin domain — exit code 1, three problems. DirectAdmin 1.712, AlmaLinux 9.8, 6 Oct 2026.
Terminal output of redirect-tester.sh on DirectAdmin after Force SSL
After Force SSL — exit code 0, all four variants end on https://. DirectAdmin 1.712, 6 Oct 2026. IP addresses masked.

Options

  • --file FILE — read URLs from a file, in addition to any on the command line.
  • --variants — test http/https and www/non-www for each argument.
  • --max-hops=N — stop after N redirects (default 10).
  • --timeout=SEC — per-request timeout (default 10).
  • --user-agent=STR — User-Agent to send; some firewalls answer unknown agents differently.
  • -q, --quiet — print only URLs with a WARN or FAIL, plus the summary line.

Running it from cron

0 6 * * * /root/bin/redirect-tester.sh --file /root/redirects.txt -q > /root/redirects.out || mail -s "Redirect problems" you@example.com < /root/redirects.out

Keep the list of important old URLs from your last migration in that file and you will know the day a rule disappears.

Notes

Read-only: it sends one GET per hop with curl and discards the body. It does not follow HTML meta refresh or JavaScript redirects, only HTTP status codes and Location headers. Certificate errors are reported as failures, not ignored; the SSL expiry check covers the certificate side. Results can differ by User-Agent and by country when a CDN or security plugin is in front of the site.

Redirect chain checker at a glance

Redirect Chain Checker summary card: After a migration, an HTTPS switch or a permalink change, redirects pile up.
In short: After a migration, an HTTPS switch or a permalink change, redirects pile up.
Redirect Chain Checker sections: What the redirect chain checker flags, Usage, Sample output and Options
Covers: What the redirect chain checker flags, Usage, Sample output and Options.
Redirect Chain Checker questions answered: Why not just use curl -L? Is a 302 always wrong?
Answers: Why not just use curl -L? Is a 302 always wrong?

Official documentation: cPanel & WHM documentation, AlmaLinux wiki, Linux man pages.

Related guides: VoIP Call Quality: Jitter, Packet Loss and MOS Explained With 3 Easy Targets · cPanel Quotas Unlimited: Easy fixquotas Repair · cPanel ELevate AlmaLinux 8 to 9 to 10: Safe Upgrade Steps.

The script

redirect-tester.shDownload
#!/usr/bin/env bash
# Redirect Chain Checker (v1.0.0) - from srvScripts.com
# Source, docs and updates: https://srvscripts.com/scripts/redirect-tester/
# Copyright (c) 2026 srvScripts.com. MIT licence: if you copy, share or adapt this script, keep this notice and credit srvScripts.com.
# redirect-tester.sh — follow redirect chains hop by hop and flag loops, long chains, 302s, HTTPS downgrades and bad endings
# https://srvscripts.com/scripts/redirect-tester/   License: MIT
#
# Read-only: it only sends GET requests with curl, one hop at a time, and prints every hop.
#   bash redirect-tester.sh http://example.com/old-page https://example.com/blog
#   bash redirect-tester.sh --file urls.txt -q          # only URLs with problems
#   bash redirect-tester.sh --variants example.com      # http/https x www/non-www
set -uo pipefail
export LC_ALL=C

usage() {
  cat <<'EOF'
Usage: redirect-tester.sh [options] URL|DOMAIN ...

Options:
  --file FILE         read URLs from FILE (one per line, # comments allowed)
  --variants          for each domain, test http/https x www/non-www and check
                      that all four end on the same URL
  --max-hops=N        give up after N redirects (default 10)
  --timeout=SEC       per-request timeout (default 10)
  --user-agent=STR    User-Agent header to send
  -q, --quiet         print only URLs that have a problem
  --no-color          plain output even on a terminal
  -h, --help          show this help

A bare domain (example.com) is tested as http://example.com/.
Exit codes: 0 all OK, 1 at least one WARN/FAIL, 2 usage or dependency error
EOF
}

MAXHOPS=10; TIMEOUT=10; UA='srvscripts-redirect-tester/1.0'; VARIANTS=0; QUIET=0; COLOR=1
TARGETS=()
read_list() {
  [[ -r $1 ]] || { echo "Cannot read $1" >&2; exit 2; }
  local l
  while IFS= read -r l; do l=${l%%#*}; l=${l//[[:space:]]/}; [[ -n $l ]] && TARGETS+=("$l"); done < "$1"
}
while (( $# )); do
  case $1 in
    --file) read_list "${2:-}"; shift ;;
    --file=*) read_list "${1#*=}" ;;
    --variants) VARIANTS=1 ;;
    --max-hops=*) MAXHOPS=${1#*=} ;;
    --max-hops) MAXHOPS=${2:-}; shift ;;
    --timeout=*) TIMEOUT=${1#*=} ;;
    --timeout) TIMEOUT=${2:-}; shift ;;
    --user-agent=*) UA=${1#*=} ;;
    --user-agent) UA=${2:-}; shift ;;
    -q|--quiet) QUIET=1 ;;
    --no-color) COLOR=0 ;;
    -h|--help) usage; exit 0 ;;
    -*) echo "Unknown option: $1 (see --help)" >&2; exit 2 ;;
    *) TARGETS+=("$1") ;;
  esac
  shift
done
[[ $MAXHOPS =~ ^[0-9]+$ && $TIMEOUT =~ ^[0-9]+$ ]] || { echo "--max-hops and --timeout need a number" >&2; exit 2; }
(( ${#TARGETS[@]} )) || { usage >&2; exit 2; }
command -v curl >/dev/null 2>&1 || { echo "curl is required and was not found." >&2; exit 2; }

C_OK=''; C_WARN=''; C_FAIL=''; C_OFF=''
if [[ -t 1 && $COLOR -eq 1 ]]; then C_OK=$'\e[32m'; C_WARN=$'\e[33m'; C_FAIL=$'\e[31m'; C_OFF=$'\e[0m'; fi
ERRF=$(mktemp) || exit 2
trap 'rm -f "$ERRF"' EXIT

OUT=''                                    # output of the current check, printed at the end
add()  { OUT+="$*"$'\n'; }
flag() {                                  # flag OK|WARN|FAIL message
  local c=$C_OK; [[ $1 == WARN ]] && c=$C_WARN; [[ $1 == FAIL ]] && c=$C_FAIL
  add "$(printf '  %s%-4s%s  %s' "$c" "$1" "$C_OFF" "$2")"
  [[ $1 != OK ]] && ISSUES=$((ISSUES + 1))
}
normalize() { [[ $1 == *://* ]] && printf '%s' "$1" || printf 'http://%s/' "$1"; }

# Follow one URL. Sets FINAL_URL and FINAL_CODE; adds hop lines and verdicts to OUT.
follow() {
  local url=$1 hop=0 code next reason redirects=0 temp=0 downgrade=0
  local -A seen=()
  FINAL_URL=$url; FINAL_CODE=000; ISSUES=0
  add "== $url =="
  while :; do
    seen[$url]=1
    reason=$(curl -sS -o /dev/null --max-time "$TIMEOUT" -A "$UA" -w '%{http_code} %{redirect_url}' -- "$url" 2>"$ERRF")
    code=${reason%% *}; next=${reason#* }; [[ $next == "$reason" ]] && next=''
    hop=$((hop + 1)); FINAL_URL=$url; FINAL_CODE=$code
    if [[ $code == 000 ]]; then
      add "$(printf '  %2d  ERR  %s' "$hop" "$url")"
      flag FAIL "request failed: $(head -n1 "$ERRF" | sed 's/^curl: ([0-9]*) //')"
      break
    fi
    if [[ $code =~ ^3 && -n $next ]]; then
      add "$(printf '  %2d  %s  %s -> %s' "$hop" "$code" "$url" "$next")"
      redirects=$((redirects + 1))
      [[ $code == 302 || $code == 303 || $code == 307 ]] && temp=$((temp + 1))
      [[ $url == https://* && $next == http://* ]] && downgrade=1
      if [[ -n ${seen[$next]:-} ]]; then flag FAIL "redirect loop: $next was already visited"; FINAL_CODE=loop; break; fi
      if (( redirects >= MAXHOPS )); then flag FAIL "gave up after $MAXHOPS redirects (--max-hops)"; FINAL_CODE=toolong; break; fi
      url=$next
      continue
    fi
    add "$(printf '  %2d  %s  %s' "$hop" "$code" "$url")"
    [[ $code =~ ^3 ]] && flag FAIL "$code response without a Location header"
    break
  done

  (( downgrade )) && flag WARN "redirects from HTTPS down to plain HTTP"
  [[ $FINAL_CODE == 200 && $FINAL_URL == http://* ]] && flag WARN "ends on plain HTTP: $FINAL_URL"
  (( redirects > 2 )) && flag WARN "chain of $redirects redirects (more than 2 costs time and crawl budget)"
  (( temp > 0 )) && flag WARN "$temp temporary redirect(s) (302/303/307); a permanent move should be 301 or 308"
  if [[ $FINAL_CODE =~ ^[0-9]+$ && $FINAL_CODE != 200 && $FINAL_CODE != 000 && ! $FINAL_CODE =~ ^3 ]]; then flag FAIL "final status $FINAL_CODE (expected 200)"; fi
  (( ISSUES == 0 )) && flag OK "$redirects redirect(s), ends 200 on $FINAL_URL"
  return 0
}

# Print OUT unless quiet mode is on and the check had no issues.
emit() { if (( ! QUIET || ISSUES > 0 )); then printf '%s\n' "$OUT"; fi; OUT=''; }

TOTAL_ISSUES=0; CHECKED=0
for t in "${TARGETS[@]}"; do
  if (( VARIANTS )); then
    host=${t#*://}; path=/
    [[ $host == */* ]] && path="/${host#*/}"
    host=${host%%/*}; base=${host#www.}
    finals=(); vissues=0
    vlist=("http://$base$path" "http://www.$base$path" "https://$base$path" "https://www.$base$path")
    for v in "${vlist[@]}"; do
      follow "$v"; emit
      CHECKED=$((CHECKED + 1)); vissues=$((vissues + ISSUES)); finals+=("$FINAL_CODE $FINAL_URL")
    done
    ISSUES=0
    add "== Variants of $base$path =="
    for i in 0 1 2 3; do add "$(printf '  %-40s -> %s' "${vlist[i]}" "${finals[i]}")"; done
    distinct=$(printf '%s\n' "${finals[@]}" | sort -u | wc -l)
    if (( distinct > 1 )); then flag FAIL "the four variants end on $distinct different URLs; pick one canonical URL and 301 the rest to it"
    else flag OK "all four variants end on ${finals[0]#* }"; fi
    emit
    TOTAL_ISSUES=$((TOTAL_ISSUES + vissues + ISSUES))
  else
    follow "$(normalize "$t")"; emit
    CHECKED=$((CHECKED + 1)); TOTAL_ISSUES=$((TOTAL_ISSUES + ISSUES))
  fi
done

echo "Checked $CHECKED URL(s); $TOTAL_ISSUES problem(s)."
(( TOTAL_ISSUES > 0 )) && exit 1
exit 0
Version 1.0.0 · SHA-256 e802a8a5354cf3312f4f09481b4bc56616db1706356ba2d22e23f04c3c7b0e4e
Download and verify on Linux or macOS
curl -fsSL -o redirect-tester.sh https://scr.srvscripts.com/redirect-tester/redirect-tester.sh && curl -fsSL https://scr.srvscripts.com/redirect-tester/redirect-tester.sh.sha256 | sha256sum -c
Download and verify in Windows PowerShell
Invoke-WebRequest -Uri 'https://scr.srvscripts.com/redirect-tester/redirect-tester.sh' -OutFile 'redirect-tester.sh'; if ((Get-FileHash 'redirect-tester.sh' -Algorithm SHA256).Hash -eq 'E802A8A5354CF3312F4F09481B4BC56616DB1706356BA2D22E23F04C3C7B0E4E') { 'OK: the file is intact' } else { 'MISMATCH: do not run this file' }
Copy the whole line. In Windows PowerShell, curl and sha256sum are not the Linux tools, so use the PowerShell line there.
Also on GitHub: github.com/srvscripts/scripts

Frequently asked questions

Why not just use curl -L?

curl -L jumps straight to the final page and prints only its headers. Stepping one hop at a time shows every status code, which is where 302s and extra hops hide.

Is a 302 always wrong?

No. Logins, carts and language switches use temporary redirects on purpose. For moved pages and http-to-https or www rules, use 301 or 308.

How many redirects are too many?

One is normal and two are acceptable. Beyond that every hop adds a round trip for visitors, and search engines may stop following long chains.

Does it send any data about my sites anywhere?

No. The only traffic is the requests to the URLs you give it.

Changelog

  • 1.0.0 — Initial release

Free website test

Is your website set up right?

Check SSL, security headers, redirects, robots.txt, sitemap, llms.txt and security.txt in one test. It takes about 30 seconds.