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 / Error | Root Cause | Primary Fix Directive |
|---|---|---|
| HTTP 500 Immediately | Invalid directive, bad regex syntax, or unauthorized flag | Run apache2ctl configtest & inspect error.log |
AH00124: 10 internal redirects | Rewrite recursion / circular internal loop | Add terminal condition (!-f, !-d) or replace [L] with [END] |
| Browser Circular Redirects | External 301/302 loop | Check Location headers; isolate %{HTTPS} & canonical host logic |
| HTTP $\to$ HTTPS Loop | TLS 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 DirectorySlash | Align trailing slash rules with native Apache directory indexing |
| Query Parameters Disappear | Target URL omitted query string during replacement | Append [QSA] (Query String Append) flag |
| Query Parameters Persist Unwanted | Default behavior re-appends existing query string | Append [QSD] (Query String Discard) flag |
| Rewrites Ignored Completely | Per-directory overrides disabled or incorrect document root | Set AllowOverride FileInfo (or All) in virtual host <Directory> |
Rule Works in Vhost but Fails in .htaccess | Per-directory paths strip leading slashes | Remove leading slash (^/path $\to$ ^path) in .htaccess |
RewriteRule: bad flag delimiters | Malformed flag list syntax | Ensure comma-separated flags inside brackets: [R=301,L,QSA] |
| Arbitrary Filesystem Access | Unsafe variable substitution | Sanitize 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:
- Client requests
products/widget. - Rule rewrites URI to
index.php?path=products/widget. [L]ends Pass 1.- Apache starts Pass 2 with URI
index.php. - Rule matches
index.php(because^(.+)$matches any character). - URI rewritten to
index.php?path=index.php. - 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:trace8enabled on a high-throughput production server. Writing megabytes of string-matching telemetry to disk creates severe I/O bottlenecks and worker thread stalls. Revert toLogLevel warnimmediately 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:
- User requests
/docs/. .htaccessstrips the slash $\to$ Redirects to/docs.- Browser requests
/docs. mod_dirdetects/docsis a directory $\to$ Redirects to/docs/.- 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:
- 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>
- 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.
- Synthetic Status Code Validation: Continuously probe critical API routes, checkout funnels, and marketing URLs using Pingzo Uptime Monitoring. Assert that responses return
200 OKrather than500or repeated301chains. - 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.
- Log Scraping: Ingest
/var/log/apache2/error.loginto your SIEM/monitoring stack and configure P1 alerts onAH00124occurrences.
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].
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.