Previous Article
Multi-Language Sites in CodeIgniter 4: URL Locales, Not Session Switching
2 June 2018
The .htaccess file lets you configure Apache per-directory without touching the main server config — clean URLs, redirects, caching, security headers. This is a practical reference to the rules worth knowing, with the details that make the difference between a rule that works and a 500 error.
Two things to know up front.
It only works on Apache. nginx ignores .htaccess entirely — its rewrites live in the server config with different syntax. If you are on nginx, none of this applies.
It has a performance cost. Apache checks for .htaccess files in every directory on every request. If you control the main server config (a VPS, not shared hosting), putting these rules in a <Directory> block there is faster. .htaccess earns its place on shared hosting, where you have no other option, and for rules a site owner needs to change without server access.
URL rewriting needs mod_rewrite enabled and AllowOverride All set for your directory in the Apache config. Without the latter, your .htaccess is silently ignored — a common "my rules do nothing" cause.
sudo a2enmod rewrite sudo systemctl restart apache2
Every rewrite file starts by turning the engine on:
RewriteEngine On
The most common use — turn profile.php?username=amit into /amit:
RewriteEngine On
# Don't rewrite requests that map to a real file or directory —
# otherwise your CSS, images and JS get rewritten too and break.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^([a-zA-Z0-9_-]+)/?$ profile.php?username=$1 [L,QSA]
The two RewriteCond lines are essential and the old tutorials usually omit them. Without !-f and !-d, Apache tries to rewrite every request — including /style.css — through profile.php, and your assets stop loading.
The single trailing /? handles both /amit and /amit/, so you do not need the two separate rules the original used.
Most modern PHP apps route everything through one entry point. This is the rule Laravel, Symfony and most frameworks ship:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
Real files are served directly; everything else goes to index.php, which reads $_SERVER['REQUEST_URI'] and decides what to do.
The bracketed flags change how a rule behaves, and getting them wrong is most of the pain:
| Flag | Meaning |
|---|---|
[L] | Last — stop processing further rules if this matches |
[R=301] | Redirect the browser (permanent). R=302 is temporary |
[QSA] | Query String Append — keep any existing ?params |
[NC] | No Case — match case-insensitively |
[F] | Forbidden — return 403 |
[R=301,L] | The common pairing for a permanent redirect |
301 versus 302 matters more than it looks. A 301 is permanent and heavily cached by browsers — set one up wrongly and visitors keep being redirected even after you fix it, because their browser remembers. Use 302 while testing, and switch to 301 only once you are certain.
Pick one canonical form and redirect the rest, or search engines see duplicate content and your ranking is split across variants.
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# Non-www → www
RewriteCond %{HTTP_HOST} ^example\.com$ [NC]
RewriteRule ^ https://www.example.com%{REQUEST_URI} [R=301,L]
# Or the reverse: www → non-www
RewriteCond %{HTTP_HOST} ^www\.example\.com$ [NC]
RewriteRule ^ https://example.com%{REQUEST_URI} [R=301,L]
Combine HTTPS and www into one redirect where you can — chaining two separate 301s makes every visitor take two round trips before the page loads.
RewriteCond %{HTTP_HOST} ^(www\.)?olddomain\.com$ [NC]
RewriteRule ^(.*)$ https://www.newdomain.com/$1 [R=301,L]
Preserving $1 means olddomain.com/about lands on newdomain.com/about, not just the homepage — which keeps the SEO value of every individual old URL.
ErrorDocument 404 /errors/404.html ErrorDocument 403 /errors/403.html ErrorDocument 500 /errors/500.html
Use a root-relative path (/errors/404.html), not a full http:// URL. Pointing ErrorDocument at an absolute URL makes Apache serve the error with a 302 redirect and a 200 status — so search engines think your missing pages exist, which is actively harmful. A local path returns the correct 404 status.
The directive is ErrorDocument, capital E and D — the lowercase errorDocument in the old version is not recognised.
# Stop Apache listing directory contents when there's no index file
Options -Indexes
# Block access to sensitive files
<FilesMatch "\.(env|log|ini|sql|bak|config)$">
Require all denied
</FilesMatch>
# Protect .htaccess itself
<Files ".htaccess">
Require all denied
</Files>
Options -Indexes matters for security, not just tidiness — without it, a directory lacking an index file shows its entire contents to anyone, which routinely leaks backups and config files.
Note Require all denied is the Apache 2.4 syntax. The older Order deny,allow / Deny from all was removed in 2.4 — if you copy a pre-2.4 snippet it throws a 500.
Stop other sites embedding your images and consuming your bandwidth:
RewriteEngine On
RewriteCond %{HTTP_REFERER} !^$
RewriteCond %{HTTP_REFERER} !^https?://(www\.)?example\.com [NC]
RewriteRule \.(jpe?g|png|gif|webp|svg)$ - [F]
The first condition allows an empty referer, so direct visits and privacy-conscious browsers still see images.
Real performance wins that belong in .htaccess:
# Compress text responses
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css text/javascript application/javascript application/json
</IfModule>
# Cache static assets aggressively
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/webp "access plus 1 year"
ExpiresByType text/css "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
ExpiresByType text/html "access plus 0 seconds"
</IfModule>
Cache assets long, HTML not at all. Use versioned filenames (app.4f2c.css) so a long cache never serves stale code — a cache-busting query string or hash in the name lets you set a one-year expiry safely.
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
</IfModule>
Add HSTS only once HTTPS is fully working — it tells browsers to refuse HTTP for a year, which is painful to reverse if something is misconfigured.
The order of causes to check, most common first:
AllowOverride is not All for your directory — the whole file is ignored.mod_rewrite is not enabled.!-f / !-d conditions — assets get rewritten and break.[L] stops the ones below it from running.Order deny,allow. Check the Apache error log; it names the offending line.Test redirects with curl -I https://example.com/old-page — it shows the status code and Location header without your browser's caching getting in the way.
.htaccess is Apache-only, and slower than main-config rules where you have the choice.!-f and !-d so real files are left alone.ErrorDocument at a local path, not an absolute URL.Require all denied).Advertisement
Written by
Amit VermaFounder / Senior Software Engineer
Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.
More articles by AmitWe ship software that scales. Let's work together.