Scope and Inheritance in .htaccess
One of the most powerful aspects of .htaccess files is their ability to apply configuration at different levels of your directory structure. A single .htaccess file at the root of your site can set rules for everything beneath it, while a more specific .htaccess deeper in the tree can modify or override those rules for just that section.
Understanding how scope works, how nested files merge, and when directives override versus append is essential for writing predictable configurations. This guide breaks down each of these concepts with clear examples so you can confidently manage multi-level .htaccess setups.
Directory-Level Scope
An .htaccess file applies its directives to the directory where it is located and to every subdirectory beneath it. This is the fundamental scoping rule, and it governs all .htaccess behavior.
Consider this directory structure:
/var/www/html/
├── .htaccess
├── index.html
├── about.html
├── blog/
│ ├── index.html
│ └── post.html
├── shop/
│ ├── index.html
│ └── product.html
└── assets/
├── style.css
└── script.js
If you place the following .htaccess in /var/www/html/:
Header set X-Content-Type-Options "nosniff"
This header is applied to every response served from /var/www/html/ and all of its subdirectories: blog/, shop/, assets/, and anything deeper. Every HTML page, every CSS file, every JavaScript file, and every image served from anywhere under the document root will include this header.
There is no way for an .htaccess file to reach upward in the directory tree. An .htaccess file in /var/www/html/blog/ cannot affect files in /var/www/html/shop/ or in /var/www/html/ itself. Its scope is strictly limited to its own directory and below.
/var/www/html/.htaccess → Affects everything
/var/www/html/blog/.htaccess → Affects only /blog/ and its subdirectories
/var/www/html/shop/.htaccess → Affects only /shop/ and its subdirectories
Because of this downward-only scope, place site-wide rules (security headers, HTTPS enforcement, compression) in the root .htaccess file. Place section-specific rules (access restrictions, custom rewrites) in the relevant subdirectory's .htaccess file.
How Nested .htaccess Files Merge
When a request comes in, Apache does not just read a single .htaccess file. It walks the entire directory path from the document root down to the directory containing the requested file, collecting and merging .htaccess files along the way.
The Merge Process
Suppose your site has this structure:
/var/www/html/
├── .htaccess ← File A
├── blog/
│ ├── .htaccess ← File B
│ └── 2024/
│ ├── .htaccess ← File C
│ └── my-post.html
When a visitor requests /blog/2024/my-post.html, Apache processes .htaccess files in this order:
- File A (
/var/www/html/.htaccess) is read first. - File B (
/var/www/html/blog/.htaccess) is read second, and its directives are merged with those from File A. - File C (
/var/www/html/blog/2024/.htaccess) is read last, and its directives are merged with the accumulated result.
The key word here is merged. Apache does not discard the parent configuration when it finds a child .htaccess file. Instead, it combines them, with the child taking precedence when there is a conflict.
A Concrete Merging Example
File A (/var/www/html/.htaccess):
ErrorDocument 404 /errors/404.html
Header set X-Frame-Options "DENY"
File B (/var/www/html/blog/.htaccess):
Header set X-Robots-Tag "index, follow"
File C (/var/www/html/blog/2024/.htaccess):
Header set X-Frame-Options "SAMEORIGIN"
For a request to /blog/2024/my-post.html, the effective configuration is:
| Directive | Value | Source |
|---|---|---|
ErrorDocument 404 | /errors/404.html | File A (inherited, no override) |
X-Frame-Options | SAMEORIGIN | File C (overrides File A's DENY) |
X-Robots-Tag | index, follow | File B (inherited, no override) |
Notice how:
- The
ErrorDocumentfrom File A is inherited all the way down because neither File B nor File C overrides it. - The
X-Robots-Tagfrom File B is inherited into File C's scope because File C does not set it. - The
X-Frame-Optionsfrom File A is overridden by File C, which sets a different value.
Parent vs. Child Directory Behavior
The relationship between parent and child .htaccess files follows a simple priority rule: the deepest (most specific) file wins when directives conflict. But the behavior depends on the type of directive.
Directives That Replace
Most directives follow a "last one wins" model. If a directive in a child .htaccess sets the same configuration as a parent, the child's value replaces the parent's:
Parent (/var/www/html/.htaccess):
DirectoryIndex home.html
Child (/var/www/html/blog/.htaccess):
DirectoryIndex blog-home.html
When a visitor accesses /blog/, Apache looks for blog-home.html as the index file, not home.html. The child completely replaces the parent's DirectoryIndex for that directory and everything beneath it.
However, when a visitor accesses the root /, Apache uses home.html because the child .htaccess has no effect outside its own directory.
Directives That Accumulate
Some directives naturally accumulate rather than replace. A notable example is mod_rewrite rules. When a parent directory has rewrite rules and a child directory also has rewrite rules, the behavior depends on whether the child includes RewriteEngine On.
Parent (/var/www/html/.htaccess):
RewriteEngine On
RewriteRule ^old-page$ /new-page [R=301,L]
Child (/var/www/html/blog/.htaccess):
RewriteEngine On
RewriteRule ^featured$ /blog/featured-post.html [L]
In this case, the child's RewriteEngine On directive effectively resets the rewrite rules for the /blog/ directory. The parent's rewrite rule for ^old-page$ does not apply inside /blog/. This is a common source of confusion.
When you add RewriteEngine On in a child .htaccess, it creates a new rewrite scope for that directory. The parent's rewrite rules are not inherited into the child's scope. If you need the parent's rules to also apply in the child directory, you must duplicate them in the child's .htaccess file.
Inheritance Without Override
If a child .htaccess does not address a particular directive at all, the parent's value is inherited unchanged. This is the default behavior and it works silently:
Parent (/var/www/html/.htaccess):
Options -Indexes
ErrorDocument 403 /errors/403.html
ErrorDocument 404 /errors/404.html
Header set X-Content-Type-Options "nosniff"
Child (/var/www/html/blog/.htaccess):
ErrorDocument 404 /blog/not-found.html
Inside /blog/:
Options -Indexesis inherited from the parent. Directory listing is disabled.ErrorDocument 403is inherited from the parent. The global 403 page is used.ErrorDocument 404is overridden by the child. The blog-specific 404 page is used.X-Content-Type-Optionsis inherited from the parent. The header is present.
The child only changed what it explicitly needed to change. Everything else flows down automatically.
Override vs. Append
Understanding whether a directive overrides or appends is critical for avoiding unexpected behavior.
Override Behavior
Most directives in .htaccess follow override semantics. When the same directive appears in both a parent and child file, the child's version completely replaces the parent's:
Parent:
Header set Cache-Control "max-age=3600"
Child:
Header set Cache-Control "max-age=86400"
Inside the child's directory, only max-age=86400 is applied. The parent's value is gone entirely for that scope.
Append Behavior
The Header directive offers explicit control over override and append behavior through its action keywords. Using append instead of set adds to the existing value rather than replacing it:
Parent:
Header set Cache-Control "public"
Child:
Header append Cache-Control "max-age=86400"
Inside the child's directory, the resulting header is Cache-Control: public, max-age=86400. The parent's value is preserved and the child's value is appended.
Other Header actions include:
| Action | Behavior |
|---|---|
set | Replaces the header entirely. If the header exists, the old value is removed. |
append | Adds the value to the existing header, separated by a comma. |
add | Adds the header even if one with the same name already exists (creates a duplicate header). |
unset | Removes the header entirely. |
merge | Appends the value only if it is not already present in the header. |
A Common Pitfall: Unintended Override
A frequent mistake is setting a header in the root .htaccess and then unintentionally wiping it out in a subdirectory:
Root (/var/www/html/.htaccess):
Header set X-Frame-Options "DENY"
Header set X-Content-Type-Options "nosniff"
Header set Cache-Control "public, max-age=3600"
Blog (/var/www/html/blog/.htaccess):
# Developer only wanted to change cache duration
Header set Cache-Control "public, max-age=86400"
The Cache-Control header is correctly updated for the blog section. But what about X-Frame-Options and X-Content-Type-Options? They are still inherited and still active because the child did not override them. This is the expected behavior.
However, if the developer had written:
# Accidentally unsets a security header
Header unset X-Frame-Options
Header set Cache-Control "public, max-age=86400"
Now the blog section loses the X-Frame-Options header entirely, which could create a clickjacking vulnerability. Always be deliberate about which headers you modify in child files.
FilesMatch and DirectoryMatch Blocks
Block directives like <FilesMatch> allow you to apply rules to specific files within the current scope, adding another layer of granularity on top of the directory-level scope of .htaccess.
FilesMatch
<FilesMatch> uses a regular expression to match file names. It is one of the most useful block directives in .htaccess:
# Apply long cache to static assets
<FilesMatch "\.(css|js|jpg|jpeg|png|gif|svg|woff2)$">
Header set Cache-Control "max-age=31536000, public, immutable"
</FilesMatch>
# Block access to sensitive files
<FilesMatch "\.(env|log|ini|bak|sql)$">
Require all denied
</FilesMatch>
The first block sets aggressive caching headers, but only for files with matching extensions. HTML files in the same directory are unaffected. The second block prevents anyone from downloading configuration files, logs, or database dumps.
Combining FilesMatch with Inherited Directives
<FilesMatch> blocks interact with inherited directives in an important way. They are processed after general directory-level directives, meaning they can override broader rules for specific files:
Root (/var/www/html/.htaccess):
# Default: short cache for all content
Header set Cache-Control "max-age=3600"
# Override: long cache for static assets
<FilesMatch "\.(css|js|jpg|png|woff2)$">
Header set Cache-Control "max-age=31536000, public"
</FilesMatch>
A request for index.html gets Cache-Control: max-age=3600. A request for style.css gets Cache-Control: max-age=31536000, public. The <FilesMatch> block takes precedence for matching files because Apache processes <Files> and <FilesMatch> directives after general directory-level directives.
Files vs. FilesMatch
The <Files> directive matches a literal file name (or a simple wildcard), while <FilesMatch> uses a regular expression:
# Files: matches exactly "secret.txt"
<Files "secret.txt">
Require all denied
</Files>
# FilesMatch: matches any file ending in .txt
<FilesMatch "\.txt$">
Require all denied
</FilesMatch>
Use <Files> when you need to target a single, specific file. Use <FilesMatch> when you need pattern matching across multiple files.
Nesting FilesMatch Inside Other Conditions
You can nest <FilesMatch> inside <IfModule> blocks for additional safety:
<IfModule mod_headers.c>
<FilesMatch "\.(css|js)$">
Header set Cache-Control "max-age=31536000, public"
</FilesMatch>
</IfModule>
This ensures the Header directive is only applied if mod_headers is available, and even then, only for CSS and JavaScript files.
Scope of FilesMatch Within the Directory Tree
A <FilesMatch> block in a parent .htaccess applies to matching files in the parent directory and all subdirectories, following the same inheritance rules as regular directives:
Root (/var/www/html/.htaccess):
<FilesMatch "\.php$">
Header set X-Powered-By-Custom "MyApp"
</FilesMatch>
This header is applied to every .php file across the entire site. If you want to override this for a specific subdirectory, place a new <FilesMatch> in that directory's .htaccess:
API directory (/var/www/html/api/.htaccess):
<FilesMatch "\.php$">
Header unset X-Powered-By-Custom
Header set X-API-Version "2.0"
</FilesMatch>
Now .php files in /api/ get the X-API-Version header instead of X-Powered-By-Custom, while .php files everywhere else on the site still receive the original header.
A Visual Summary of Scope and Inheritance
To tie everything together, consider this complete example:
/var/www/html/
├── .htaccess ← [A]
├── index.html
├── blog/
│ ├── .htaccess ← [B]
│ ├── index.html
│ └── drafts/
│ ├── .htaccess ← [C]
│ └── draft-post.html
└── assets/
└── style.css
[A] /var/www/html/.htaccess:
Options -Indexes
ErrorDocument 404 /errors/404.html
Header set X-Frame-Options "DENY"
<FilesMatch "\.(css|js)$">
Header set Cache-Control "max-age=31536000"
</FilesMatch>
[B] /var/www/html/blog/.htaccess:
ErrorDocument 404 /blog/not-found.html
[C] /var/www/html/blog/drafts/.htaccess:
Require all denied
Here is the effective configuration for different requests:
| Request | Options | 404 Page | X-Frame-Options | Cache-Control | Access |
|---|---|---|---|---|---|
/index.html | -Indexes (A) | /errors/404.html (A) | DENY (A) | Not set | Allowed |
/assets/style.css | -Indexes (A) | /errors/404.html (A) | DENY (A) | max-age=31536000 (A) | Allowed |
/blog/index.html | -Indexes (A) | /blog/not-found.html (B) | DENY (A) | Not set | Allowed |
/blog/drafts/draft-post.html | -Indexes (A) | /blog/not-found.html (B) | DENY (A) | Not set | Denied (C) |
Each request shows how directives are inherited from parent files and selectively overridden by child files, with <FilesMatch> adding file-specific behavior on top.