Skip to content

Powered by Grav

Custom rewrites & location blocks

Custom rewrites & location blocks

BOA has no .htaccess layer — every per-site rewrite, redirect, or custom location block is delivered through an operator include file dropped into a fixed directory and pulled into the rendered vhost at a defined position. The include file is never touched by the Provision backend, so it survives every barracuda upgrade and every Verify.

The two include points

The runtime vhost (Inc/vhost_include.tpl.php) globs two operator filenames from the per-frontend post.d directory and includes each at a different depth:

Filename Included at Override depth
nginx_force_include.conf early in the vhost, before BOA's standard location set (vhost_include.tpl.php:364) high — can override cache-bypass and static-file handling
nginx_vhost_include.conf mid-body — after the early cache-bypass and static-file locations but before the private-download, CSS/JS, and PHP-handler locations (vhost_include.tpl.php:1154) standard — overrides rules "below" it (private-download, CSS/JS, the @cache/@drupal/PHP handlers), not the higher-level cache-bypass/static rules above it

Both are globbed with a trailing *, so a nginx_force_include.conf.foo suffix variant is also picked up. A sibling fpm_include* glob (vhost_include.tpl.php:369) is pulled in alongside nginx_force_include for per-site PHP-FPM version overrides.

Choose nginx_vhost_include.conf first; switch to nginx_force_include.conf only when the override does not take because a higher-priority BOA block is winning the match.

Drop directories

The include is read from $aegir_root/config/server_master/nginx/post.d/, where $aegir_root resolves per frontend:

Frontend post.d drop directory
Octopus Satellite (per instance) /data/disk/<USER>/config/server_master/nginx/post.d/
Master (host-level Ægir frontend) /var/aegir/config/server_master/nginx/post.d/

Both rows carry the server_master/nginx/post.d/ suffix — that is the only path the rendered vhost's include directive reads. A file dropped into /var/aegir/config/includes/ (a real directory that holds the ip_access/ generated includes) is never picked up as a custom rewrite; that directory is not on the post.d include path.

Drop the file, then re-emit the vhost (below). The file's content is copied by reference at include time, not merged into the vhost, so your edits persist verbatim across upgrades.

Scope of custom rules

A custom include applies to every site on the same frontend unless each block is scoped to a host. The canonical pattern wraps each interesting block in an if ($host ~* …) { … } or a server_name guard so it only fires for one domain. Without that guard the rule leaks to every vhost the include is pulled into.

Recipes

Map legacy content paths to multisite

NGINX
location ~* ^.+\.(?:jpe?g|gif|png|ico|swf|pdf|ttf|html?)$ {
  access_log off;
  log_not_found off;
  expires 30d;
  rewrite ^/files/(.*)$     /sites/$server_name/files/$1 last;
  rewrite ^/images/(.*)$    /sites/$server_name/files/images/$1 last;
  rewrite ^/downloads/(.*)$ /sites/$server_name/files/downloads/$1 last;
  rewrite ^/download/(.*)$  /sites/$server_name/files/download/$1 last;
  rewrite ^/docs/(.*)$      /sites/$server_name/files/docs/$1 last;
  rewrite ^/documents/(.*)$ /sites/$server_name/files/documents/$1 last;
  rewrite ^/legacy/(.*)$    /sites/$server_name/files/legacy/$1 last;
  try_files $uri =404;
}

For migrations where legacy URLs (/files/foo.pdf) must land under Drupal's multisite-aware /sites/<domain>/files/.

Site-specific 301 with a parent literal location

The parent literal location ^~ /path stops Nginx from searching other regex-based locations, so the nested regex wins over BOA's own regex blocks:

NGINX
location ^~ /some-literal-path/no-regex-here {
  location ~* ^/some-path/or-regex-here {
    if ($host ~* ^(www\.)?(domain\.com)$) {
      return 301 $scheme://$host/destination/url;
    }
    try_files $uri @cache;
  }
}

Without the parent literal block, the regex location loses to BOA's regex blocks.

Bulk 301 for legacy .php URIs

NGINX
location ^~ /services {
  location ~* ^/services {
    rewrite ^/services/accounting\.php$ $scheme://$host/node/18 permanent;
    rewrite ^/services/assurance\.php$  $scheme://$host/node/11 permanent;
    rewrite ^/services/audit\.php$      $scheme://$host/node/11 permanent;
    rewrite ^/services/taxation\.php$   $scheme://$host/node/92 permanent;
    rewrite ^/services/wealth\.php$     $scheme://$host/node/15 permanent;
    rewrite ^/services\.php$            $scheme://$host/node/17 permanent;
    try_files $uri @cache;
  }
  try_files $uri @cache;
}

For sites migrated off Joomla / Drupal 6 / legacy WordPress that exposed .php URIs. Note that bare .php requests are also subject to the abuse-guard .php catch-all (see Edge policy); a matched literal location short-circuits before that guard.

Domain-specific single-URL redirect

NGINX
location = /about_us.php {
  if ($host ~* ^(www\.)?(foo\.com)$) {
    return 301 $scheme://$host/node/19;
  }
  return 444;
}

location = /… is the most specific match — it short-circuits location matching entirely. return 444 (Nginx's "close without response") quietly drops the request for other hosts that hit the same URI.

Avoid 404s on sites/default/files/ legacy paths

For a site moved from sites/default/ to sites/<domain>/:

NGINX
if ($main_site_name = '') {
  set $main_site_name "$server_name";
}

location ^~ /sites/default/files {
  location ~* ^/sites/default/files/imagecache {
    access_log off;
    log_not_found off;
    expires 30d;
    set $nocache_details "Skip";
    rewrite ^/sites/default/files/imagecache/(.*)$ /sites/$main_site_name/files/imagecache/$1 last;
    try_files $uri @drupal;
  }
  location ~* ^/sites/default/files/styles {
    access_log off;
    log_not_found off;
    expires 30d;
    set $nocache_details "Skip";
    rewrite ^/sites/default/files/styles/(.*)$ /sites/$main_site_name/files/styles/$1 last;
    try_files $uri @drupal;
  }
  location ~* ^/sites/default/files {
    access_log off;
    log_not_found off;
    expires 30d;
    rewrite ^/sites/default/files/(.*)$ /sites/$main_site_name/files/$1 last;
    try_files $uri =404;
  }
}

How the include interacts with the rendered vhost

Provision renders the vhost in two parts: the per-site vhost.tpl.php frames the plain-HTTP server block (on an SSL site, Ssl/vhost_ssl.tpl.php frames the :443 block first and then includes vhost.tpl.php for the HTTP one), while Inc/vhost_include.tpl.php is rendered once per frontend as the shared nginx_vhost_common.conf (under config/includes/, pulled into every vhost) carrying the Drupal-specific locations, the cache and static-file handling, and the PHP-FPM upstream. The two include points land your file at one of the two well-defined positions inside that shared body — force_include before any location block at all, vhost_include mid-body: after the early cache-bypass and static-file locations but ahead of the private-download, CSS/JS, and PHP (@cache/@drupal/.php$) handlers.

Response headers BOA already sets — and the ones it deliberately does not

Before adding add_header lines to a custom include, know what the rendered vhost already sends on every site:

  • X-Content-Type-Options "nosniff" and X-Frame-Options "SAMEORIGIN", emitted at server scope (vhost_include.tpl.php:345-346) and re-stated verbatim inside every static-serving location that declares an add_header of its own — aggregated CSS/JS, image derivatives, CDN far-future, webform files, the short-URI /files/ variants, \.xml$ — because nginx's inheritance rule would otherwise silently drop the pair exactly there — location @cache included (vhost_include.tpl.php:1749-1750; subdir twin subdir.tpl.php:1242-1243), because it serves a static Boost file through try_files with no application in the loop, so nothing else could own those headers there. The deliberate exceptions are the application handlers proper (location = /index.php and the ESI microcache location): their own add_header sets cancel the inherited pair, leaving document-response security headers to the application layer, where Drupal already sends its own X-Frame-Options. The subdir template carries the same arrangement, with its base pair declared at the top of the subdir master location block (subdir.tpl.php:240-241);
  • Referrer-Policy "no-referrer-when-downgrade" on dynamic document responses — it sits in the location = /index.php handler's own add_header set (vhost_include.tpl.php:1964; subdir equivalent subdir.tpl.php:1166);
  • Alt-Svc 'h3=":443"; ma=86400' when HTTP/3 is enabled (vhost_include.tpl.php:353).

Just as deliberately, BOA emits no Strict-Transport-Security and no Content-Security-Policy anywhere — HSTS commits every subdomain of a domain to HTTPS in a way an operator must consciously choose, and a CSP is site-specific by nature. If you want either, they are yours to add through the include files above.

Mind nginx's add_header inheritance when you do: a location block that declares any add_header of its own stops inheriting the ones declared at the levels above it — so a custom location that adds one header silently drops BOA's nosniff/SAMEORIGIN pair for responses served from that location. Re-state them alongside your own header inside any block where you use add_header.

(One more built-in worth knowing while you are in the rendered vhost: location = /llms.txt routes to the site's own files directory with a per-host variant tried first — vhost_include.tpl.php:534-541 — so an AI-usage policy is published by dropping a file, no rewrite rule needed.)

Applying the change

A custom include takes effect on the next Nginx reload — the include globs are already baked into every rendered vhost, so a newly dropped or edited post.d file needs no re-emission, just service nginx reload. Running a Verify works too (it reloads Nginx at the end), and is the way to go when your change should coincide with a vhost re-render:

SH
# As the Aegir user, via Drush:
drush @<site-alias> provision-verify

Verify rewrites the vhost to /var/aegir/config/server_master/nginx/vhost.d/<site> (Master) or the /data/disk/<USER>/…/vhost.d/<site> equivalent on an Octopus instance, re-pulls the post.d includes, and reloads Nginx. Note that Verify does not run nginx -t first — a syntax error in your include surfaces only at the reload, so test with nginx -t yourself before triggering it.

Debugging an include that does not take

  1. Read the rendered vhost on disk — confirm the include directive in the emitted vhost.d/<site> references your post.d file.
  2. Check syntaxnginx -t reports errors from include files with the include path and line.
  3. Reload after a config-only editservice nginx reload. (Verify auto-reloads; a manual edit of the include does not until the next reload.)
  4. Rule out the cache — the Speed Booster cache may be serving the old response. Bypass with a .dev. host or wait for the cache TTL.
  5. Wrong include point — if a higher-priority BOA block is winning, move the rule from nginx_vhost_include.conf to nginx_force_include.conf.
  • Config templates — the BOA-deployed templates and the Provision master http{} config your includes layer over.
  • Nginx debugging — diagnosing 502/504 and the redirect-to-install loop.
  • Edge policy — the guard chain (.php catch-all, secret-path deny) a literal-location include short-circuits past.
  • Reference appendix — the consolidated variable and command indexes.

© 2026 BOA Documentation. All rights reserved.