Short answer. .htaccess is an Apache configuration file that applies to the directory it sits in and every directory below it. Apache re-reads it on every request, so edits take effect immediately without a restart. It only works when the administrator has allowed overrides via AllowOverride, and nginx ignores it entirely.
What .htaccess is and when it actually works
Apache parses its main configuration once at startup. On top of that it can read distributed configuration files — one per directory. The default name for such a file is .htaccess. The point of the mechanism is to let a site owner on shared hosting change web server behaviour inside their own directory without access to the main config and without the right to restart the service.
Three conditions must hold or the file does nothing at all:
- Apache serves the site (or the compatible LiteSpeed). Plain nginx never reads .htaccess under any configuration — that is a deliberate architectural difference, not a missing feature.
- Overrides are allowed. The virtual host config must set
AllowOverrideto something other thanNonefor your directory. WithNone, Apache will not even open the file: it sits there and silently does nothing. - The required module is loaded. A
RewriteRulewithoutmod_rewrite, anExpiresByTypewithoutmod_expiresor aHeaderwithoutmod_headersdoes not degrade to "rule ignored" — it returns a 500 for the whole directory.
AllowOverride is not a yes/no switch but a list of directive groups: FileInfo (redirects and rewrites), AuthConfig (password protection), Limit (IP-based access), Indexes (directory listing), Options. Hence a common shared-hosting situation: redirects work fine, but Options -Indexes throws a 500 — one group is permitted and another is not.
Where the file lives and how to create it
There is no canonical path. The file belongs in the site root, and every host names that root differently — public_html, www, htdocs, httpdocs, site/public. Do not go by the directory name; go by where the index.php or index.html served at your domain actually sits.
The name starts with a dot, so the file is hidden. Enable "show hidden files" in your panel's file manager and in your FTP client, or you will conclude the file does not exist and create a second one.
# create an empty file and see what is already in the root
cd /var/www/example.com/public_html
ls -la | grep -i htaccess
printf '' > .htaccess
chmod 644 .htaccess
The requirements for the file itself are simple, and they are exactly what breaks edits:
- Encoding must be UTF-8 without BOM. Apache treats the three invisible BOM bytes as garbage before the first directive and answers 500.
- Line endings must be LF. A file saved by a Windows editor with CRLF usually works, but line continuations and quoted arguments behave unpredictably.
- Permissions
644, owned by the user the site runs as. The web server cannot read a600file owned by somebody else. - One directive per line; comments start with
#. A trailing comment on a directive line is not allowed — it is parsed as an argument.
Back the file up before every edit. cp .htaccess .htaccess.bak-$(date +%F) takes a second, whereas reconstructing a broken redirect from memory on a live site takes a long time. The general approach to backups is covered in the website backup guide.
How Apache applies directives: cascade and inheritance
When a request arrives, Apache resolves the path on disk and walks every directory from the document root down to the target, reading a distributed config file in each one. Directives are merged: a file in a subdirectory extends its parent and overrides it on conflict.
Two non-obvious consequences follow.
First, mod_rewrite rules are not inherited by default. If the root defines a front controller and you create your own file in a subdirectory containing any RewriteRule, the parent rule set stops applying to that subdirectory entirely. You can restore inheritance with RewriteOptions Inherit, but the better answer is usually to keep all rules in the root file instead of scattering them.
Second, RewriteBase sets the base path for rules with relative substitutions. When a site moves from a subdirectory to the root or back, a forgotten RewriteBase is the single most common reason for "it all worked on the old host".
Within one file, directives apply top to bottom. The [L] flag ends the current pass, but if a rule rewrote the path internally, Apache runs the whole rule set again — which is where loops come from.

Redirects: HTTPS, www canonicalisation and single pages
For plain path-to-path cases, Redirect from mod_alias is enough — it is faster and unambiguous. You need RewriteRule when the decision depends on conditions: scheme, host, whether a file exists, a request header.
# Simple per-page move (mod_alias)
Redirect 301 /old-page.html /new-page/
RedirectMatch 301 ^/blog/([0-9]+)/(.*)$ /articles/$2
# Canonical address in a single hop (mod_rewrite)
RewriteEngine On
# 1) www to non-www, straight to https
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^ https://%1%{REQUEST_URI} [R=301,L]
# 2) http to https; behind a reverse proxy the scheme comes from a header, not %{HTTPS}
RewriteCond %{HTTPS} !=on
RewriteCond %{HTTP:X-Forwarded-Proto} !=https
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
The order of these rules is not arbitrary. A request to http://www.example.com/page matches the first rule and lands on https://example.com/page in one hop. Swap the rules and the same request takes two hops: first to https with www, then to https without it. An extra hop is an extra round trip for the visitor and a diluted signal for search engines; see 301 vs 302 and redirects and SEO.
A word on %{HTTPS}. When nginx, a load balancer or a CDN sits in front of Apache, TLS terminates there and the request reaches Apache over plain http. In that setup %{HTTPS} is always off, so the condition "if not https, redirect" is true on every request. The result is an infinite loop and ERR_TOO_MANY_REDIRECTS for every visitor. The header set by the proxy is what you must test.

Access control: IP rules, password protection, hiding internal files
Access control syntax changed between Apache 2.2 and 2.4, and that is the source of most "sudden" 500s after a migration. The old Order, Allow from and Deny from directives only work in 2.4 when the mod_access_compat module is loaded; without it they are a configuration error.
# Apache 2.4 — current syntax
<RequireAll>
Require all granted
Require not ip 203.0.113.10
Require not ip 198.51.100.0/24
</RequireAll>
# Apache 2.2 — legacy syntax (needs mod_access_compat)
Order allow,deny
Allow from all
Deny from 203.0.113.10
Password protection has two parts: a file of password hashes and the directives that point at it. The hash file must never live inside the public directory, or it can simply be downloaded.
# create the password file OUTSIDE the document root
htpasswd -c /var/www/example.com/.htpasswd admin
# .htaccess inside the protected directory
AuthType Basic
AuthName "Restricted area"
AuthUserFile /var/www/example.com/.htpasswd
Require valid-user
The path in AuthUserFile must be absolute; a relative path returns 500. The -c flag recreates the file from scratch and wipes existing users — omit it when adding a second account.
Basic authentication sends the login and password in a header on every request. Without HTTPS that is plaintext on the wire. Only use it on a site with a working certificate — you can verify one with the SSL checker. A second caveat: if the application has its own authentication, it reads the Authorization header too, and the two systems can collide badly enough to lock you out of the admin area.
Internal files deserve an explicit deny rather than security by obscurity. The usual set is environment configs, database dumps, logs, backups and version control directories:
# refuse to serve internal files
<FilesMatch "^\.|\.(env|ini|log|sql|sql\.gz|bak|old|sh)$">
Require all denied
</FilesMatch>
# turn off directory listing where there is no index file
Options -Indexes
# refuse to execute PHP inside the uploads directory
<FilesMatch "\.(php|phtml|php[0-9])$">
Require all denied
</FilesMatch>
That last block belongs in the user uploads directory. It is one of the few measures that genuinely stops a web shell uploaded through a leaky form: the file reaches the disk, but it cannot be executed over the web. If the infection already happened, see the malware check guide.

Caching, compression and response headers
When a site has no access to the server config, .htaccess is the only place to control static asset caching and compression. Both have a real effect and both are easy to get wrong.
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/css text/xml
AddOutputFilterByType DEFLATE application/javascript application/json
AddOutputFilterByType DEFLATE image/svg+xml
</IfModule>
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
ExpiresByType image/webp "access plus 6 months"
ExpiresByType image/svg+xml "access plus 6 months"
ExpiresDefault "access plus 1 hour"
</IfModule>
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set Referrer-Policy "strict-origin-when-cross-origin"
</IfModule>
The IfModule wrapper is not decoration: without it, a missing module takes the whole directory down with a 500. A one-year cache is only safe for files with a version in the name or query string — otherwise visitors keep the stale CSS until it expires. HTML must not be cached for long: the content changes and users keep seeing the previous version of the page.
Compressing already-compressed data is pointless and harmful: JPEG, PNG and WebP images, video and archives burn CPU without shrinking. What compression buys on which content types is covered in the gzip vs Brotli comparison, and cache design in caching strategies.
Pretty URLs and the CMS block you must not edit
Nearly every CMS uses the same scheme: if the requested file and directory do not physically exist on disk, the request goes to an entry script that parses the address itself.
# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress
The BEGIN and END marker lines are not comments for humans — they are the boundaries the CMS uses to find its own block and rewrite it whenever permalink settings are saved. Anything you put between them will vanish one day without warning. Add your own rules above the block (if they must run first) or below it, but never inside.
Order matters here: a canonical-address redirect belongs above the CMS block so that it fires before control passes to the entry script.
A 500 right after an edit, and other classic failures
The key diagnostic fact: apachectl configtest validates the main configuration and does not look inside .htaccess. An error in a distributed file only appears when a request arrives, and it is written to the site's error log. So step one is always the same — open the log.
# recent Apache errors
tail -n 50 /var/log/apache2/error.log # Debian, Ubuntu
tail -n 50 /var/log/httpd/error_log # RHEL, AlmaLinux, CentOS
# redirect chain and response headers
curl -sIL https://example.com | grep -i -E '^HTTP/|^location:'
curl -sI https://example.com/assets/style.css | grep -i -E 'cache-control|content-encoding'
What the most common messages mean:
| Symptom or log line | Cause | What to do |
|---|---|---|
Invalid command 'RewriteEngine' | mod_rewrite is not loaded | Enable the module; on shared hosting, ask support |
Invalid command 'php_value' | PHP runs as FPM or CGI, not as an Apache module | Move PHP settings to .user.ini or the hosting panel |
.htaccess: Option Indexes not allowed here | Directive outside the permitted AllowOverride groups | Widen AllowOverride in the config or drop the directive |
| 500 immediately after saving, empty log | BOM at the start of the file or broken encoding | Re-save as UTF-8 without BOM with LF line endings |
ERR_TOO_MANY_REDIRECTS | The redirect condition is still true after the redirect | Test X-Forwarded-Proto behind a proxy; remove overlapping rules |
| 403 across the whole directory | Require all denied is broader than intended, or there is no index file with Options -Indexes | Narrow the condition; see the 403 Forbidden guide |
| The rule simply does nothing | AllowOverride None, wrong directory, or an earlier [L] already matched | Check the directory and the rule order |
When the log gives you nothing, a crude but fast method works: comment out half the file and repeat the request. Two or three iterations localise the line faster than re-reading the config. The general treatment of 500s is in the Internal Server Error guide.
What .htaccess costs you
Apache parses its main config once at startup and keeps it in memory. Distributed files work differently: with AllowOverride set to anything but None, the server checks for the file in every directory along the path — from the document root to the target — on every single request, and re-parses whatever it finds. For a page with a hundred static assets that is hundreds of extra filesystem calls.
Hence the practical rule: if you have access to the virtual host config, move the rules there and set AllowOverride None on the directory. This is not micro-optimisation for its own sake — on a busy site the difference shows up in the response time graph. How to chase slowdowns systematically is covered in high server load, and the difference between plans with and without config access in shared hosting vs VPS vs dedicated.

Hosting on nginx: .htaccess does not work — what to do
nginx does not support distributed configuration files by design and will not start. A file in the site root on nginx is just a text file nobody reads. Rules move into the server block and are applied with a configuration reload; the syntax and structure are covered in the nginx configuration guide, and where to look when debugging in nginx logs.
One setup deserves separate mention: nginx in front of Apache, proxying dynamic requests while serving static files itself. There, .htaccess rules apply to PHP pages and do not apply to images and stylesheets — caching headers for static assets have to be set in nginx. To find out which one you are facing, use technology detection and check the Server response header.
How to verify the rules actually applied
An .htaccess edit is not done when the file is saved; it is done when the change is visible in the server response. A quick verification round:
- Redirect checker — the full hop chain with status codes: both extra hops and loops are visible.
- HTTP header checker — whether
Cache-Control,Content-Encodingand security headers took effect. - Speed test — the effect of compression and caching on a real page load.
- Security scanner — whether any internal files or directory listings are still exposed.
- robots.txt checker — if you manage indexing with an
X-Robots-Tagheader, reconcile it with the file rules. - Uptime monitoring — after a redirect change it is worth confirming the site answers with the expected code from outside your network.
Do not test the homepage alone. Redirects usually break the edge cases: addresses with query strings, nested directories, non-ASCII paths, and pages with and without a trailing slash.
Frequently asked questions
Why does .htaccess do nothing even though the file is in the root?
Three reasons, by frequency: the site is served by nginx, which never reads such files; the config says AllowOverride None; the file is in the wrong directory — often one level above the document root. You can check the first from the Server response header; the second only via the virtual host config or your host's support.
Can I use .htaccess on nginx?
No. This is not a configuration question: nginx deliberately does not read configuration from site directories, for performance reasons. The rules have to be rewritten into a server block and the configuration reloaded.
How do I block indexing with .htaccess?
With Header set X-Robots-Tag "noindex, nofollow" from mod_headers. Unlike robots.txt, this also works for non-HTML files: PDFs, images, dumps. Do not combine it with a robots.txt disallow for the same address — a crawler that is forbidden to fetch the page never sees the header either.
Do I need .htaccess if I have access to the server config?
No, and you are better off without it. Everything a distributed file does, the virtual host config does faster and with a syntax check before the restart. Keep .htaccess where there is no config access, or where the rules must be editable by someone without administrator rights.
Why does the browser loop after a redirect edit while curl looks fine?
Browsers cache a 301 for a long time, sometimes until you clear site data. Test changes in a private window or with a tool that does not keep a redirect cache, and do not ship a 301 before the rule is verified: downgrading it to a 302 afterwards is hard, because the old response is already stored on users' machines.
Where is .htaccess in WordPress, and what happens if I delete it?
In the site root, next to wp-config.php. Without it, permalinks stop working: every address except the homepage returns 404, because requests no longer reach the entry script. The file is recreated automatically when permalink settings are saved again, provided the web server can write to the directory.
.htaccess edit checklist
- A dated backup of the file exists.
- The file is in the document root, saved as UTF-8 without BOM, mode 644.
- Every module-specific block is wrapped in
IfModule. - Canonical redirects reach the target in a single hop.
- Behind a reverse proxy the scheme is taken from
X-Forwarded-Proto, not%{HTTPS}. - Access control syntax matches the Apache version (
Requirefor 2.4). - The password file lives outside the public directory and
AuthUserFileholds an absolute path. - Internal files are blocked and script execution in uploads is denied.
- Your own rules sit outside your CMS's
BEGINandENDmarkers. - After the edit you checked the redirect chain, the response headers and several edge-case URLs — not just the homepage.