Decision records
ADR-0031: `.htaccess` compatibility (opt-in per application)
- Status: Accepted (2026-10-06)
Context
Hosting customers bring applications written for Apache (WordPress, Laravel, Joomla,
Drupal, …) whose routing, redirects and access rules live in .htaccess files they edit
themselves. LiteSpeed and similar servers read them; without that, migrating a site means
rewriting its rules by hand.
Decision
applications[].htaccess: true(default false) makes scalws read.htaccessfiles from the document root down to the requested directory. New cratescalws-htaccess: parser and evaluator, pure and unit-tested; the pipeline applies the result before the handler.- Supported (the subset common applications use):
- mod_rewrite:
RewriteEngine,RewriteBase,RewriteCond(server variables,%{HTTP:Header},-f -d -s,=,<,>, regex,[NC,OR]),RewriteRulewith[L,END,R=code,NC,QSA,QSD,NE,F,G,S=n,C];$nand%nback-references. - mod_alias:
Redirect,RedirectPermanent,RedirectTemp,RedirectMatch. - Access:
Require all granted|denied,Require [not] ip …, legacyOrder/Allow/Deny, inside<Files>/<FilesMatch>too. ErrorDocument(local paths and URLs),DirectoryIndex,Header set|append|unset,ExpiresActive/ExpiresByType/ExpiresDefault,<IfModule>(true for the modules above; a block for another module is skipped, but access rules in it deny, as below),Options(no effect: scalws never lists directories).
- mod_rewrite:
- Apache semantics kept: rewrite rules apply from the deepest
.htaccessthat has them; per-directory patterns match the path relative to that directory; an internal rewrite restarts rule processing (at most 10 rounds) unless[END]; other directives are merged, deeper overriding shallower. The rewritten path re-selects the route of the application;REQUEST_URIstays the original for PHP. - Security — the file is written by the tenant:
- no proxy (
[P]), noRewriteMap, nophp_admin_*, noSetHandler/AddHandler: such lines are ignored and reported (logs,scalwsctldiagnostics); - regular expressions use the linear-time
regexcrate; patterns it cannot express (back-references inside patterns, look-around) make that rule inactive, not slow; - limits: 64 KiB per file, 1000 directives, 10 rewrite rounds; each regular expression
compiles within 32 KiB, and at most 8 per file within 256 KiB (they run on the shared
event loops); the parsed-file cache is weighed by compiled size (4 KiB for nearly every
pattern, so long redirect lists stay cached) and by path length (64 MiB per app); an
application may spend at most 100 ms per second parsing files: past that, a directory
whose file is not cached denies until the next second, so the cost stays with that
tenant; a request’s regular expressions,
<FilesMatch>included, may scan at most 8 MiB in all rounds, each run costing 4 KiB on top of its subject (about 1 ms; past that the request answers 500); cached files are weighed by their source, warnings and stored items too;DirectoryIndexkeeps at most 16 names; access rules past the directive or size limits deny (a<Files *>deny placed after the file’s own<Files>blocks), also below child directories that grant access; - a local
ErrorDocumentthe deny rules block is not served: the plain error is (the path of its URI is checked, without query or fragment); - rewrites stay inside the application;
-f/-dchecks use the confined lookup of ADR-0006;.htaccessitself is never served (dotfiles are 404).
- no proxy (
- Cost: parsed files are cached per directory and re-checked (one
statx) at most every 2 s, as LiteSpeed does; absent files are cached too.
Consequences
- Unsupported directives are ignored with a warning instead of failing the site, except
access rules: an access rule scalws cannot evaluate (HTTP authentication
Auth*/Require valid-user|user|group|host|env, access rules inside<RequireAll>,<RequireAny>,<Limit>,<LimitExcept>,<If>, or a<Files>/<FilesMatch>with access rules and a pattern the regex engine cannot take) denies every request to that directory (or every name, for the<Files>block) with 403. Ignoring it would publish what the tenant meant to protect (security audit run-3 C8). The same holds for access rules inside<IfModule>for a module scalws does not model, after the 1000-directive limit, or in the part of a file beyond 64 KiB (run-4), and for<Files>blocks inside such containers, left open or never closed: they deny every name (run-5). php_value/php_flagare not applied yet (FPM’sPHP_VALUEpersists in the worker).