Back to blog
Linux & DevOps October 2, 2026

How to Test and Debug Apache .htaccess Rewrite Rules Without Crashing the Server

Automate WhatsApp Alerts
Start Free ➔

Apache .htaccess rewrite rules are evaluated at request-processing time within a per-directory context. Because mod_rewrite operates as an internal state machine, per-directory rewrites can execute the ruleset repeatedly in successive passes as the URI is transformed.

A single malformed directive, invalid regular expression, missing flag, or non-terminating rewrite rule can trigger an immediate HTTP 500 Internal Server Error (AH00124: Request exceeded the limit of 10 internal redirects) or lock clients into circular redirect loops (301/302).

# Verify Apache core syntax and loaded modules
sudo apache2ctl configtest
sudo apache2ctl -M | grep rewrite

30-Second Triage: Apache Rewrite Failures & Fixes

Symptom / ErrorRoot CausePrimary Fix Directive
HTTP 500 ImmediatelyInvalid directive, bad regex syntax, or unauthorized flagRun apache2ctl configtest & inspect error.log
AH00124: 10 internal redirectsRewrite recursion / circular internal loopAdd terminal condition (!-f, !-d) or replace [L] with [END]
Browser Circular RedirectsExternal 301/302 loopCheck Location headers; isolate %{HTTPS} & canonical host logic
HTTP $\to$ HTTPS LoopTLS terminated before Apache (Load Balancer / Cloudflare)Evaluate X-Forwarded-Proto header instead of %{HTTPS}
/foo $\to$ /foo/ $\to$ /foo Loop.htaccess rule conflicts with mod_dir DirectorySlashAlign trailing slash rules with native Apache directory indexing
Query Parameters DisappearTarget URL omitted query string during replacementAppend [QSA] (Query String Append) flag
Query Parameters Persist UnwantedDefault behavior re-appends existing query stringAppend [QSD] (Query String Discard) flag
Rewrites Ignored CompletelyPer-directory overrides disabled or incorrect document rootSet AllowOverride FileInfo (or All) in virtual host <Directory>
Rule Works in Vhost but Fails in .htaccessPer-directory paths strip leading slashesRemove leading slash (^/path $\to$ ^path) in .htaccess
RewriteRule: bad flag delimitersMalformed flag list syntaxEnsure comma-separated flags inside brackets: [R=301,L,QSA]
Arbitrary Filesystem AccessUnsafe variable substitutionSanitize captures or upgrade to Apache $\ge$ 2.4.60

1. The mod_rewrite Processing Engine Lifecycle

To debug Apache rewrite rules without breaking production, engineers must recognize that per-directory rewriting is not a single top-to-bottom string replace. It is an iterative state loop managed by the Apache request pipeline.

HTTP Request Arrives
       │
       ▼
┌───────────────────────────────┐
│ Apache Core Request Pipeline  │
└──────────────┬────────────────┘
               │
               ▼
┌───────────────────────────────┐
│ Per-Directory (.htaccess)     │ ◄────────────────────────┐
│ Strips directory prefix path  │                          │
└──────────────┬────────────────┘                          │
               │                                           │
               ▼                                           │
┌───────────────────────────────┐                          │
│ Evaluate RewriteRule Regex    │                          │
└──────────────┬────────────────┘                          │
               │                                           │
          Match? ─── No ──► Next Rule in .htaccess         │
               │                                           │
              Yes                                          │
               ▼                                           │
┌───────────────────────────────┐                          │
│ Evaluate RewriteCond Gates    │                          │
└──────────────┬────────────────┘                          │
               │                                           │
          Passed? ── No ──► Next Rule in .htaccess         │
               │                                           │
              Yes                                          │
               ▼                                           │
┌───────────────────────────────┐                          │
│ Apply Substitution & Flags    │                          │
└──────────────┬────────────────┘                          │
               │                                           │
       ┌───────┴─────────────────────────┐                 │
       │                                 │                 │
       ▼                                 ▼                 │
External Redirect                 Internal Rewrite         │
  ([R=301] / [R=302])               ([L] flag)             │
       │                                 │                 │
       ▼                                 ▼                 │
Send HTTP response to client      Re-inject URI into       │
Client initiates new request      Request Pipeline ────────┘
                                         │
                                   Is [END] set?
                                   ├── Yes ──► Terminate per-dir passes
                                   └── No  ──► Re-evaluates .htaccess
                                               (Max 10 passes $\to$ AH00124)

Context Mismatch: Server Config vs. .htaccess

In virtual host context (/etc/apache2/sites-available/app.conf), the matching URI always includes the leading slash:

# VirtualHost Context: Matches full path
RewriteRule "^/products/(.*)$" "/catalog/$1" [R=301,L]

In per-directory context (/var/www/html/.htaccess), Apache strips the leading directory path prior to pattern evaluation. Matching on ^/products will fail silently:

# .htaccess Context: Leading slash is stripped by Apache
RewriteRule "^products/(.*)$" "catalog/$1" [R=301,L]

2. [L] vs [END]: Preventing Infinite Internal Loops

The single greatest source of 500 Internal Server Error outages in Apache is confusing the [L] (Last) flag with the [END] flag.

The Problem with [L]

[L] stops rule evaluation for the current pass. However, when an internal substitution occurs, Apache re-injects the modified URI back into the request processing engine. The new URI triggers another .htaccess pass from the very beginning.

If the new URI still satisfies the rule's regex or lacks exclusion guards, Apache loops indefinitely until reaching LimitInternalRecursion (default: 10):

# BROKEN: Creates infinite internal loop -> AH00124 (HTTP 500)
RewriteEngine On
RewriteRule "^(.+)$" "index.php?path=$1" [L,QSA]

Execution trace of the bug:

  1. Client requests products/widget.
  2. Rule rewrites URI to index.php?path=products/widget.
  3. [L] ends Pass 1.
  4. Apache starts Pass 2 with URI index.php.
  5. Rule matches index.php (because ^(.+)$ matches any character).
  6. URI rewritten to index.php?path=index.php.
  7. Repeats 10 times $\to$ Apache terminates the worker thread with AH00124.

The Solution: Using [END] or File Conditionals

Apache 2.4 introduced the [END] flag, which halts rule execution and prevents any subsequent per-directory rewrite passes for that request:

# PRODUCTION FIX 1: Use [END] with File/Directory Exclusion Guards
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule "^(.+)$" "index.php?path=$1" [END,QSA]
# PRODUCTION FIX 2: Explicit Terminal Guard Rule
RewriteEngine On
RewriteRule "^index\.php$" "-" [END]

RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule "^(.*)$" "index.php?path=$1" [L,QSA]

3. Real-Time Diagnostics with Fine-Grained LogLevel Tracing

Apache 2.4 allows per-module logging levels. Instead of overwhelming server logs with global debug output, isolate mod_rewrite execution.

Enabling rewrite:trace in Virtual Hosts

Add LogLevel warn rewrite:traceN inside your <VirtualHost> or server configuration (levels range from trace1 to trace8):

# /etc/apache2/sites-available/000-default.conf
<VirtualHost *:80>
    ServerName example.com
    DocumentRoot /var/www/html

    # Level 3: Rules matched & substitutions
    # Level 8: Full regex backtrack, token parsing, and internal URI states
    LogLevel warn rewrite:trace3

    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>
# Validate config syntax before applying
sudo apache2ctl configtest

# Perform graceful reload
sudo systemctl reload apache2

Reading Trace Output

Stream and filter live rewrite engine execution:

# Monitor Apache rewrite trace live
sudo tail -f /var/log/apache2/error.log | grep '\[rewrite:'

A healthy trace3 log snippet displays the exact transformation steps:

[rewrite:trace2] [pid 14201] mod_rewrite.c(483): [client 192.168.1.50:52410] 
  init rewrite engine with requested uri /catalog/widget
[rewrite:trace3] [pid 14201] mod_rewrite.c(483): [client 192.168.1.50:52410] 
  applying pattern '^products/(.*)$' to uri 'catalog/widget'
[rewrite:trace1] [pid 14201] mod_rewrite.c(483): [client 192.168.1.50:52410] 
  pass through /var/www/html/catalog/widget

[!WARNING] Never leave rewrite:trace8 enabled on a high-throughput production server. Writing megabytes of string-matching telemetry to disk creates severe I/O bottlenecks and worker thread stalls. Revert to LogLevel warn immediately after debugging.


4. The Four Fatal .htaccess Production Traps

Trap 1: HTTPS Redirect Loops Behind Reverse Proxies / CDNs

When TLS is terminated upstream (Cloudflare, AWS ALB, Nginx Reverse Proxy), traffic between the proxy and Apache travels over plain HTTP.

Browser (HTTPS) ──► CDN / Load Balancer ──► Apache (HTTP :80)

If Apache checks %{HTTPS} off, it detects off and issues a 301 Moved Permanently to https://.... The browser follows the redirect, the CDN forwards it back to Apache over HTTP, and the client crashes with ERR_TOO_MANY_REDIRECTS.

# BROKEN BEHIND PROXIES:
RewriteCond %{HTTPS} off
RewriteRule "^" "https://%{HTTP_HOST}%{REQUEST_URI}" [R=301,L]
# PRODUCTION-GRADE FIX: Check X-Forwarded-Proto Header
RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteCond %{HTTP:X-Forwarded-Proto} !^https$ [NC]
RewriteRule "^" "https://%{HTTP_HOST}%{REQUEST_URI}" [R=301,END]

Test the response headers locally without relying on cached browser redirects:

# Test direct HTTP request
curl -sSI http://example.com/

# Test proxied HTTPS request simulation
curl -sSI -H "X-Forwarded-Proto: https" http://example.com/

Validate your live edge status and headers using the Pingzo HTTP Header Checker and SSL Inspector.


Trap 2: Trailing Slash Collision with mod_dir

When a requested URL matches an existing physical directory on disk, Apache's core mod_dir automatically appends a trailing slash (/folder $\to$ /folder/).

If your .htaccess has a naive stripping rule, Apache enters an infinite ping-pong loop:

# DANGEROUS: Conflicts with mod_dir DirectorySlash
RewriteEngine On
RewriteRule "^(.*)/$" "$1" [R=301,L]

Loop mechanics:

  1. User requests /docs/.
  2. .htaccess strips the slash $\to$ Redirects to /docs.
  3. Browser requests /docs.
  4. mod_dir detects /docs is a directory $\to$ Redirects to /docs/.
  5. Infinite external 301 loop.
# PRODUCTION FIX: Exclude Real Directories from Slash Normalization
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule "^(.*)/$" "/$1" [R=301,END]

Trap 3: Query Parameter Discarding vs. Pollution ([QSA] vs [QSD])

By default, when you rewrite a URI without specifying a new query string, Apache preserves the existing query parameters. However, if your substitution adds a query string, the original query string is silently overwritten unless you specify [QSA].

Conversely, if you want to eliminate legacy query parameters, Apache will retain them unless you explicitly specify [QSD].

# Case A: Preserving incoming query parameters (Search, UTM tags)
# Input: /search/laptops?sort=price&page=2
# Output: index.php?q=laptops&sort=price&page=2
RewriteRule "^search/(.+)$" "index.php?q=$1" [END,QSA]

# Case B: Discarding unwanted incoming query parameters
# Input: /legacy-page?old_param=123
# Output: /new-page (old_param stripped clean)
RewriteRule "^legacy-page$" "/new-page" [R=301,END,QSD]

Trap 4: Permissions and AllowOverride Blockers

If your .htaccess rules appear to do nothing at all:

  1. Check your primary VirtualHost definition (/etc/apache2/sites-enabled/):
<Directory "/var/www/html">
    Options Indexes FollowSymLinks
    # If set to 'None', .htaccess is completely ignored by Apache
    AllowOverride FileInfo Options Indexes
    Require all granted
</Directory>
  1. Verify file permissions:
# Ensure Apache user (www-data or apache) can read the file
ls -la /var/www/html/.htaccess
sudo chmod 644 /var/www/html/.htaccess
sudo chown www-data:www-data /var/www/html/.htaccess

5. Isolated Docker Test Rig for CI/CD

Never test rewrite rules directly on live servers. Use an isolated Docker harness to run automated assertions in GitHub Actions or local dev.

Project Structure

├── docker-compose.yml
├── Dockerfile
├── apache.conf
├── html/
│   ├── .htaccess
│   ├── index.php
│   └── assets/
│       └── style.css
└── test-rewrites.sh

Dockerfile

FROM httpd:2.4-alpine

# Enable mod_rewrite in Alpine httpd
RUN sed -i \
    -e 's/#LoadModule rewrite_module/LoadModule rewrite_module/' \
    -e 's/AllowOverride None/AllowOverride All/' \
    /usr/local/apache2/conf/httpd.conf

COPY ./html/ /usr/local/apache2/htdocs/
EXPOSE 80

test-rewrites.sh (Automated Assertion Runner)

#!/usr/bin/env bash
set -euo pipefail

BASE_URL="http://localhost:8080"

echo "=== 1. Testing Static File Pass-Through ==="
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${BASE_URL}/assets/style.css")
if [ "$STATUS" -eq 200 ]; then
  echo "✔ Static file served directly (200 OK)"
else
  echo "✖ Failed static file test (Got: $STATUS, Expected: 200)" && exit 1
fi

echo "=== 2. Testing Clean URL Front-Controller Routing ==="
BODY=$(curl -s "${BASE_URL}/products/shoes")
if echo "$BODY" | grep -q "path=products/shoes"; then
  echo "✔ Internal rewrite succeeded"
else
  echo "✖ Internal rewrite failed" && exit 1
fi

echo "=== 3. Testing 301 Canonical HTTPS Redirect ==="
HEADER=$(curl -sSI "${BASE_URL}/legacy-link")
if echo "$HEADER" | grep -q "301 Moved Permanently"; then
  echo "✔ External 301 redirect emitted"
else
  echo "✖ Expected 301 redirect missing" && exit 1
fi

echo "=== 4. Verifying No AH00124 Recursion Errors in Logs ==="
docker compose logs apache 2>&1 | grep -E 'AH00124|Request exceeded the limit' && {
  echo "✖ CRITICAL: Infinite recursion detected in Apache error log!"
  exit 1
} || echo "✔ Zero recursion errors detected."

echo "All rewrite assertions passed successfully!"

6. SRE Production Debugging Runbook

When a sudden .htaccess change causes an incident, follow this standard triage protocol:

Incident Detected (HTTP 500 / 301 Loop)
                    │
                    ▼
          ┌───────────────────┐
          │ Check Blast Radius│
          └─────────┬─────────┘
                    │
   ┌────────────────┴────────────────┐
   │                                 │
   ▼                                 ▼
Single Endpoint Failing?        All Routes Returning 500?
(Specific Regex / Flag bug)      (Global Syntax / Override bug)
   │                                 │
   ▼                                 ▼
curl -sSI http://app/broken      Run: sudo apache2ctl configtest
   │                             Inspect: /var/log/apache2/error.log
   │                                 │
   ▼                                 ▼
Look for AH00124                 Syntax OK?
(Recursion Loop)                 ├── No  ──► Fix bad directive/flag
   │                             └── Yes ──► Check AllowOverride
   ▼
Temporarily set:
LogLevel warn rewrite:trace3
   │
   ▼
Reproduce with 1 curl request:
curl -sv http://app/broken -o /dev/null
   │
   ▼
Identify offending rule, add [END] or [QSA], and revert LogLevel to warn

7. Production-Safe Universal Front Controller Template

Here is the hardened, production-tested .htaccess configuration suitable for Laravel, WordPress, Symfony, and custom Single Page Applications (SPAs):

# ==============================================================================
# Hardened Production .htaccess Template
# ==============================================================================
RewriteEngine On
RewriteBase /

# 1. Block access to hidden files (.git, .env, .htaccess)
RewriteRule "(^|/)\.(?!well-known)" - [F]

# 2. Enforce HTTPS behind Trusted Reverse Proxies / Load Balancers
RewriteCond %{HTTPS} !=on
RewriteCond %{HTTP:X-Forwarded-Proto} !^https$ [NC]
RewriteRule "^" "https://%{HTTP_HOST}%{REQUEST_URI}" [R=301,END]

# 3. Prevent Trailing Slash Collision for Real Directories
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule "^" "-" [END]

# 4. Pass-Through Existing Files (Images, CSS, JS, Fonts)
RewriteCond %{REQUEST_FILENAME} -f
RewriteRule "^" "-" [END]

# 5. Route All Remaining Dynamic Requests to Application Router
RewriteRule "^(.*)$" "index.php?path=$1" [END,QSA]

8. Continuous Edge & Synthetic Health Monitoring

Because .htaccess files can be modified by application developers without triggering central configuration alerts or requiring service reloads, bad rules frequently bypass traditional deployment gates.

  1. Synthetic Status Code Validation: Continuously probe critical API routes, checkout funnels, and marketing URLs using Pingzo Uptime Monitoring. Assert that responses return 200 OK rather than 500 or repeated 301 chains.
  2. Redirect Limit Thresholds: Set synthetic monitor alerts on redirect hops. Any chain exceeding 2 hops indicates non-canonical rules fighting with CDN or framework routing.
  3. Log Scraping: Ingest /var/log/apache2/error.log into your SIEM/monitoring stack and configure P1 alerts on AH00124 occurrences.

Check endpoint availability and status codes instantly using the Pingzo HTTP Status Code Checker and Ping Test.


Frequently Asked Questions

What is the exact difference between [L] and [END] in Apache 2.4?

[L] stops rule execution for the current pass, but an internal rewrite re-injects the modified URL into Apache's pipeline, triggering another .htaccess evaluation from the top. [END] completely stops per-directory rewriting for the request, preventing any subsequent passes and eliminating AH00124 recursion errors.

Do I need to restart Apache after editing .htaccess?

No. Apache evaluates .htaccess dynamically on every incoming request. Changes take effect immediately without running systemctl reload apache2. However, changes to VirtualHost files or global Apache configurations require syntax validation (apache2ctl configtest) and a reload.

Why does my rewrite rule work on staging but loop on production?

Staging often runs direct HTTP or local TLS, whereas production operates behind CDNs (Cloudflare, Fastly) or AWS Application Load Balancers. In production, TLS terminates at the edge, and the proxy communicates with Apache over HTTP. A rule checking %{HTTPS} off will loop unless it checks %{HTTP:X-Forwarded-Proto}.

How do I safely test 301 redirects without polluting my browser cache?

Use [R=302] (Temporary Redirect) during development and testing. Browsers cache 301 Moved Permanently responses aggressively in local disk storage, making it impossible to see subsequent rule updates without clearing browser history. Once the rule is verified via curl -sSI, change it to [R=301].

Zero-Code Uptime Alerts

Stop Finding Out About Outages from Angry Users

Get instant WhatsApp & Discord alerts the second your API, website, or server goes down. Setup in 30 seconds with 60-second checks.

WhatsApp & Discord 60-Second Checks Free Forever Plan
Try Pingzo Free

Know before your users do

Connect official WhatsApp notification channels, Discord webhooks, Telegram bots, and public status pages. Start in 30 seconds.

Create Free Monitor