How-to

.htaccess support

Turn on Apache .htaccess files for a site and know which directives ScalWS applies, which it ignores and which make it deny access.

ScalWS reads .htaccess files itself, without Apache, for applications written for Apache hosting (WordPress, Joomla, Drupal, single-page applications, redirects maintained by the site owner). The decision and its limits are in ADR-0031; the parser and evaluator are the scalws-htaccess crate.

Turn it on

Per site, with htaccess: true (in scalws.yaml: applications[].htaccess):

# /etc/scalws/sites.d/example.com.yaml
domain: example.com
root: /srv/www/example.com
php: {fastcgi: "unix:/run/php/php8.3-fpm.sock"}
htaccess: true

or scalwsctl site add example.com --root /srv/www/example.com --htaccess. It is off by default and needs the site’s root.

  • Files are read from root down to the directory of the request. The lookup starts at the site’s root, not at a runtime’s own root (for Laravel, /srv/shop, not /srv/shop/public).
  • The rules run for every request of the site, whatever the runtime (static, PHP, proxy, Node.js, …), after denied paths, rate limits and https_redirect, and before the route is chosen. A rewritten path selects the route again, and denied paths are checked again on it.
  • Parsed files are cached per directory and re-checked at most every 2 seconds: edits take effect within 2 seconds, without a reload.
  • .htaccess itself is never served (dotfiles answer 404).

What is supported

ModuleDirectives
mod_rewriteRewriteEngine, RewriteBase, RewriteCond (server variables, %{HTTP:Header}, -f, -d, -s, =, <, >, regular expressions, [NC,OR]), RewriteRule with [L,END,R=code,NC,QSA,QSD,NE,F,G,S=n,C], $n and %n back-references
mod_aliasRedirect, RedirectPermanent, RedirectTemp, RedirectMatch
AccessRequire all granted|denied, Require [not] ip ..., Order/Allow/Deny, also inside <Files> and <FilesMatch>
OtherErrorDocument, DirectoryIndex, Header set|append|add|unset, ExpiresActive, ExpiresByType, ExpiresDefault, <IfModule>, Options (no effect: directories are never listed)

Server variables available in RewriteCond: REQUEST_URI, QUERY_STRING, REQUEST_FILENAME, SCRIPT_FILENAME, DOCUMENT_ROOT, HTTP_HOST, SERVER_NAME, SERVER_PORT, HTTPS, REQUEST_SCHEME, REQUEST_METHOD, REMOTE_ADDR, THE_REQUEST, HTTP_USER_AGENT, HTTP_REFERER, HTTP_COOKIE, HTTP_ACCEPT, HTTP_AUTHORIZATION, REDIRECT_STATUS, and any request header as %{HTTP:Name}. Other variables are empty.

Apache’s semantics are kept: rewrite rules apply from the deepest .htaccess that has them, per-directory patterns match the path relative to that directory, an internal rewrite restarts rule processing (at most 10 rounds) unless [END], and other directives are merged with deeper files winning. PHP still sees the original REQUEST_URI.

What is ignored, and what denies

Ignored, with a log line naming the file:

  • proxying with [P], RewriteMap, SetHandler, AddHandler;
  • php_value, php_flag, php_admin_* (set PHP options in the pool or in php.fpm.ini instead);
  • a rule whose regular expression the linear-time engine cannot run (look-around, back-references inside a pattern): that rule is inactive;
  • directives of modules ScalWS does not model.

Denied with 403: access rules ScalWS cannot evaluate are never skipped, so nothing meant to be protected is published by accident. That covers HTTP authentication (AuthType, Require valid-user|user|group), Require host|env, access rules inside <RequireAll>, <RequireAny>, <Limit>, <LimitExcept> or <If>, and access rules in an <IfModule> for a module ScalWS does not model. The whole directory (or, for a <Files> block, every name) then answers 403. Protect such areas another way, for example with a route’s basic_auth, in the application, or with security.deny_paths.

Limits: 64 KiB per file and 1000 directives (access rules beyond them deny), 10 rewrite rounds, at most 16 DirectoryIndex names, and bounded regular-expression size and scanning per request (a request over the scanning budget answers 500).

Examples

WordPress’s own block works as written:

<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>

Redirects kept by the site owner:

Redirect 301 /old-page https://example.com/new-page
RedirectMatch 301 ^/blog/(\d+)/(.*)$ https://example.com/news/$2

Close a directory to everyone but one address, and set a header:

# /srv/www/example.com/internal/.htaccess
Require ip 192.0.2.10
Header set Strict-Transport-Security "max-age=31536000"

Check it works

curl -sI -H 'Host: example.com' http://127.0.0.1/old-page         # 301
curl -sI -H 'Host: example.com' http://127.0.0.1/internal/        # 403 from other addresses
journalctl -u scalwsd | grep htaccess                               # ignored lines
sudo grep htaccess /var/lib/scalws-controller/logs/web-0.log       # under scalws-controller

Common problems

  • Rules have no effect: htaccess: true is missing, the file is outside root, or scalws cannot read it. Look for ignored lines in the log.
  • A directory answers 403 for everyone: it (or a parent) has an access rule ScalWS cannot evaluate, such as AuthType Basic with Require valid-user.
  • A rewrite loops or answers 500: more than 10 internal rewrite rounds; end the rule with [END] or add a RewriteCond that stops it.
  • A [P] proxy rule is ignored: use a route with a proxy runtime.
  • php_value upload_max_filesize is ignored: set it in the PHP-FPM pool or in php.fpm.ini.

See also: applications: .htaccess, security: .htaccess.