Skip to content

Powered by Grav

Adopting vanilla Ægir into BOA — aegir2boa

Adopting vanilla Ægir into BOA — aegir2boa

aegir2boa adopts a vanilla Ægir 3.x estate — the classic community Ægir on Apache or Nginx, everything under /var/aegir, a single aegir system user — into a BOA Octopus account on a separate BOA box. It is the vanilla counterpart to the BOA-to-BOA xoct move, and it exists because a vanilla source has none of the control files, agents, or conventions xoct relies on, and because converting a vanilla box to BOA in place has no cheap revert.

The safe shape is the one xoct already uses: adopt the estate remotely onto the target, then demote the old box to an HTTP(S) proxy whose per-site revert is a single file move. Nothing on the source is destroyed — the original configuration stays on disk untouched until you retire the machine, and every step is reversible until the final DNS cutover.

The migration is three acting stages plus a read-only discovery phase:

TXT
stage 0   aegir2boa-preflight   read-only discovery; PASS/WARN/FAIL per stage
stage 1   aegir2boa-stage1      in-place Apache -> Nginx flip on the source
stage 2   aegir2boa-stage2      remote adoption into BOA + proxy window
stage 3   (manual runbook)      final DNS cutover + source decommission

Stage 1 exists because the whole stage-2 source-side machinery — the vhost swap, the 503 pause, the proxy templates — is nginx-vhost-based. Vanilla Ægir supports Nginx natively, so the first real step flips the box to Nginx; the stage-1 revert is cheap (flip back to Apache — both stacks stay installed until sign-off).

The toolset

Tool Runs on Role
aegir2boa-preflight source (vanilla) read-only discovery + stage gates
aegir2boa-stage1 source (vanilla) Apache→Nginx flip, revert, status
aegir2boa-stage2 both boxes the migrator; dual-resident, verbs validate the box class

All three ship inside the BOA tree at aegir/tools/bin/, but — unlike xoct, xcopy, and xmass — they are deliberately not registered for fleet fetch. No BOA box downloads or runs them by itself, and they never self-update. Download them straight onto the box that needs them:

BASH
cd /usr/local/bin
for t in aegir2boa-preflight aegir2boa-stage1 aegir2boa-stage2; do
  wget https://files.boa.io/versions/lts/boa/aegir/tools/bin/$t
  chmod 755 $t
done

The vanilla source needs nothing else to fetch them: no BOA installation, no account, no credentials. Swap lts for dev or pro to pull from another tree; all three carry the same tools. Use the short tree token — a path such as versions/5.x-lts/... answers with an HTTP 200 "Under Construction" page instead of a 404, so a typo leaves you holding a placeholder that looks like a successful download. Confirm with head -1, which must read #!/bin/bash. Then run them as root.

aegir2boa-stage2 is dual-resident: put the same script on the source and the target. Its source verbs refuse to run on a BOA box and its target verbs refuse to run on a vanilla box, so the same binary can never act on the wrong side. The source-side tools assume nothing modern — bash 3.2/4.1-era and PHP 5.3-era safe — so an old vanilla box is fine; the target verbs assume a healthy BOA box.

Validation status — read before using on a client box

Proven end-to-end on disposable VMs, most recently across three drills: on 2026-08-11 a fresh vanilla source and a fresh BOA target, with an estate carrying real Let's Encrypt HTTPS sites and a Drupal 9 composer platform; on 2026-08-12 a mixed Drupal 6 + 7 estate — Drupal 6 on d6lts, the lineage BOA itself supports — adopted into a php-max target; and on 2026-08-13 an Ubuntu source (jammy, PHP 7.4 from the era-typical PPA, distro MySQL 8.0, on the deb-installed Apache include layout) taken end to end — stage-1 flip, revert and re-flip, the check database-generation refusal exercised live against Percona 5.7 targets, and full per-site adoption of the MySQL 8.0 estate into a Percona 8.4 target, proxy window and reverts included:

  • The stage-1 flip on an encrypted estate: flip, revert and re-flip, with both HTTPS sites answering 200 on their own certificates at every step.
  • Both stage-2 routes, per-site and db-import — the db-import leg including the full two-box revert (--revert-db-import restored the pre-import panel exactly) and its refusal on ineligible estates, verified as a refusal.
  • Drupal 8+ composer platforms adopted as projects, not as their docroots: the platform is registered under its project name — so two platforms whose docroots are both web/ cannot collide — and vendor/ travels with the tree.
  • The HTTPS story end to end, with its one manual step. An ssl-bearing estate served publicly through the source-side proxy with DNS still on the old box, then the full certificate loop closed: Encryption enabled per site on the target, real Let's Encrypt certificates issued THROUGH the proxy window, and cert-sync --live mirroring the target-issued certificates back to the proxy edge. The manual step is the enable itself — adopted sites arrive with Encryption off, so the target holds no certificate until you enable it there (see cert-sync). One db-import route nuance stands (see cert-sync): db-import keeps vanilla's alias settings, so enabling Encryption there needs the www. alias added or a bare-name certificate requested.
  • Every stage-2 revert path on that HTTPS+composer estate: single-site revert with its stale-dump refusal, full two-box revert (revert --all + resume + target --reset-sites), target reset, and the retry-needs-fresh-export path taken all the way back to a serving state over already-registered panel nodes.
  • Drupal 6 estates are adopted end to end. D6 routes to the per-site path by design — a D6 platform blocks the db-import triad exactly as a D8+ one does — and each D6 site is registered with its own install profile, mapped to the target's PHP 5.6 pool in multi-fpm.info, and probed only once its pool socket exists. Drilled through adoption, the proxy window, public serving, and every revert leg, on a mixed D6+D7 estate. A D6 site needs /opt/php56 on the target (see Prerequisites).
  • Public serving through the proxy window, HTTP and HTTPS both.

Use the current published tool bytes. These tools never self-update (they are deliberately outside the fleet fetch), and the validated behaviour is the current tools' — copies downloaded earlier lack the stage-1 HTTPS flip, composer-platform adoption, the peer verb, the HTTPS proxy window itself, and site-profile carry-over, without which any site whose install profile is not standard (every Drupal 6 site included) fails its import. Re-download all three before a migration rather than reusing a copy already sitting on a box.

Which leaves, honestly:

  • A target without a php56 pool is not drilled. D6 adoption is validated against a target that has PHP 5.6 installed; the refusal ladder for a missing pool — check flagging the site, and the import's per-site FAIL when the socket never appears — exists in code but has not been exercised on a pool-less target.
  • Pre-3.x Ægir sources are refused by the preflight floor — recognised and named, never mangled. There is no supported path for them yet.
  • Panel-domain continuity is not implemented. The adopted panel lives at <oN>.<target-fqdn>; the old panel URL goes dark (a permanent 503) at proxy time. Communicate the new URL to the client.
  • The upstart-era and Apache 2.2 populations are feature-detected but undrilled. The Ubuntu axis itself is drilled, and within the Debian family the tools branch on detected features rather than on the distribution — but no cloud image exists for upstart init (Ubuntu 12.04/14.04) or Apache 2.2 (Debian 7 / Ubuntu 12.04), so those code paths are reviewed and unit-verified only. Treat an estate of that era with the usual dry-run care and read the dry-run output closely.

Safety model

The doctrine holds across every acting verb, every stage:

  • Every acting verb is a DRY RUN by default. Append --live to act. --live is accepted only after a clean dry run of the same verb and scope on the same box, and it consumes that clean token on entry — one dry run arms exactly one live run, and any failed live run forces a fresh dry run against the changed box before you can retry.
  • The serving path is never cut before its replacement is functionally proven. The stage-1 flip probes real PHP through nginx+FPM on a scratch port while Apache still serves; the stage-2 proxy swap refuses any site the target does not already answer for; every nginx change is nginx -t-gated with automatic restore on failure. Every pre-handover failure is therefore a no-outage abort.
  • Frontend tasks are polled by latest vid only (finished Ægir tasks retain stale status rows), and the tools never force-run the task queue. One scoped exception: when a task the tool itself queued still sits at queued status after a minute, the tool runs that account's own dispatcher for it, once a minute, logging the drain — nothing else dispatches a freshly created account's queue during the adoption window, so polling alone would deadlock to the timeout and count the site a failure.

Every verb is idempotent behind marker files and safe to re-run. The preflight and the stage-2 tool take a coarse per-scope lock against concurrent runs; stage 1's guard is its consumed dry-run token. The logs — /var/log/aegir2boa-stage1.log and /var/log/aegir2boa-stage2.log, both falling back to /tmp if /var/log is not writable — are the first place to look on any failure.

Prerequisites

  • A Debian-family source box — Debian, Ubuntu or Devuan, with working apt/dpkg sources and the Debian apache2 layout. Any era the estate survived on: nothing branches on the distro name, and everywhere the families or their eras genuinely diverge the tools probe the box at run time — the init system (systemctl, falling back to update-rc.d), which of the three routes the Ægir apache include takes (conf-available behind a2enconf, a bare conf-enabled symlink with no backing file, or 2.2's conf.d), the distro Nginx and OpenSSH floors, and the database flavour. A non-apt (RPM-family) source is refused cleanly: stage 1 runs its own preflight on every invocation, --status included, and stops at no apt-get - the source must be a Debian-family box. Note that this is stage 1's check, not stage 0's — the discovery preflight does not test for apt, so a wrong-family box passes discovery and is caught only when you first run aegir2boa-stage1.
  • Usable apt sources on an EOL release. The live flip installs nginx and php-fpm from the box's own repositories. A failed apt-get update is not fatal — it is named in the log (stale/EOL apt sources?, pointing at archive.debian.org / old-releases.ubuntu.com) and the install proceeds against cached lists — but if the packages then cannot be installed, stage 1 aborts with nginx install failed or php-fpm install failed. That abort is pre-handover, so Apache is still serving; repoint the apt sources and re-run. Check the sources before the live flip on any EOL release rather than discovering it mid-run.
  • Arrange reachability with the peer verb rather than by hand. The target's lfd reads an ssh-keyscan / first-contact burst as abuse and temp-blocks port 22 — proven the hard way — so the firewall must be opened before the key ever travels. The verb does both halves in the right order:
SH
# ON THE SOURCE: mint a dedicated key and print the target-side command
aegir2boa-stage2 peer --target <target-ip>                     # dry
aegir2boa-stage2 peer --target <target-ip> --live

# ON THE TARGET: open csf.allow AND csf.ignore, clear any tripped block,
# reload csf, authorise the key (the command printed above supplies it)
aegir2boa-stage2 peer --source <source-ip> --pubkey-file <f>   # dry
aegir2boa-stage2 peer --source <source-ip> --pubkey-file <f> --live

# ON THE SOURCE again: confirms it can reach root@target
aegir2boa-stage2 peer --target <target-ip> --live

Both halves are idempotent. The key carries an a2b- comment so it is easy to revoke at decommission; check verifies that BatchMode ssh works.

  • A fresh preflight report on the source. The report lands under /tmp, which is tmpfs on many boxes — a reboot eats it, and check refuses a report from a different host. Re-run the preflight after any reboot, and copy the .txt/.env pair somewhere durable for the record.
  • Stage 1 signed off: the source serves the whole estate on Nginx.
  • Target PHP pools for the estate's needs (a D6 site needs a php56 pool on the target; check grades this per site and later skips a site whose pool is missing rather than blocking the run).
  • Disk headroom: per site roughly its DB size free under /var/aegir on the source for dumps; the whole estate + 500 MB free under /data/disk on the target (transfer measures and refuses without it).
  • A shared target box can have other provisioning actors (billing automation, another operator). The existence gates catch a collision, but never pre-assume the next free oN account name — check first, and expect a dry run to fail honestly if the name got taken between your check and your live run.

Stage 0 — discovery preflight

Run on the source, as root:

SH
aegir2boa-preflight              # writes report + machine contract under /tmp
aegir2boa-preflight --help       # options; --aegir-root for non-standard layouts

Read-only by contract: it writes only under /tmp, installs nothing, changes no service, and its SQL is SELECT/SHOW-only. The one sanctioned exception is the optional drush @hostmaster status health check (a Drupal bootstrap writes cache tables); set A2B_NO_DRUSH=1 to suppress it — at the cost of a permanent frontend_bootstrap_failed WARN in that run. --aegir-root points the probe at a non-standard layout.

Outputs, freshly timestamped every run:

TXT
/tmp/aegir2boa-preflight.<host>.<timestamp>.txt    human report
/tmp/aegir2boa-preflight.<host>.<timestamp>.env    machine contract

The report opens with the two verdicts that gate everything:

  • STAGE 1 / STAGE 2: PASS — proceed.
  • WARN — proceed with named caveats. WARN does not mean non-migratable; it lists items to resolve or consciously accept. A typical healthy Ægir-on-Nginx box (after the stage-1 flip) reports WARN on both stages.
  • FAIL — do not proceed with that stage. Reasons are named tokens, e.g. cluster_pack_topology, dump_headroom, boa_box_not_vanilla.

Three hard floors refuse the box with exit 3: a BOA box (use xoct instead), a non-Ægir box, and pre-3.x Ægir. Most are caught before deep probing; a pre-3.x box recognisable only by its hostmaster DB schema is refused after the full probe pass. A2B_ALLOW_BOA=1 exists only for inspecting a BOA box's data out of curiosity — it never authorises a migration, and the verdicts stay FAIL. Exit codes: 0 report emitted, 2 precondition failure (not root, lock held, bad usage), 3 floor bail (the report is still written).

The .env machine contract (shell-safe scalars plus TSV tables) is the sole input the later stages trust — stage 2's check sources its verdict scalars rather than re-discovering the box. Keep the pair with the migration record.

Stage 1 — Apache to Nginx, in place

Run on the source, as root:

SH
aegir2boa-stage1 --flip              # dry run: gates + baseline capture
aegir2boa-stage1 --flip --live       # the flip
aegir2boa-stage1 --status            # both config planes + daemons + per-site HTTP
aegir2boa-stage1 --revert            # dry run for the way back
aegir2boa-stage1 --revert --live     # nginx -> apache

Scope: a concrete http_service_type of apache AND apache_ssl. The apache_sslnginx_ssl path is drilled — flip, revert and re-flip with the per-site certificates carried and HTTPS verified in both directions. cluster and pack topologies are refused — flip each member box instead.

What the live flip does, in order: installs nginx+php-fpm without starting daemons (a temporary policy-rc.d guard; Apache keeps :80 throughout), starts the versioned FPM pools, adds the aegir user's sudoers line for the exact nginx reload binary the backend will call, enables every frontend feature the target service class needs — the plain and SSL nginx classes come from different modules, and the dry run already FAILS if the module providing the target class is absent from the hostmaster codebase, because the flip would otherwise die at the node save after a clean dry run — then flips the server node's http_service_type exactly as the GUI radio would, with the node payload carrying the estate's own port and, for an *_ssl class, its ssl_port (queueing the server verify that writes the nginx config tree), waits out the platform verify cascade, then verifies every site (hostmaster first — the cascade stops at platforms, so this per-site loop is what populates nginx/vhost.d), asserting each site's nginx vhost file exists. Only then: nginx -t, a scratch-port FCGI probe that executes real PHP through nginx+FPM while Apache still serves, and the daemon handover (stop+disable apache, start+enable nginx and FPM — reboot-persistent). Finally every site's HTTP code is compared against the pre-flip baseline — and for every site the front end reports as encrypted, the HTTPS response too, probed with real SNI and hostname verification, so a flip that leaves a site on a regenerated self-signed certificate reads as a failure.

The FPM package is series-matched before the metas are tried: the tool derives php<major>.<minor>-fpm from the box's own CLI PHP and installs that first, falling back to php-fpm and then php5-fpm. The versioned name goes first because on a mixed-repo box the unversioned meta resolves to the newest PHP the repositories offer, which need not be the series the sites run on. The dry run gates the same three candidates and fails if none is installable. A failed apt-get update is not fatal — it is named in the log (stale or EOL apt sources, whose mirrors move to archive.debian.org / old-releases.ubuntu.com) and the install proceeds against the cached lists, so one dead third-party repository does not abort the flip. The package installs themselves remain fatal: if nginx or every FPM candidate fails to install, the flip stops there with the policy-rc.d guard lifted.

The revert proves the Apache config without binding a port while nginx still serves — the dry run parses a temp wrapper conf with apache2 -t -f; the live revert re-enables the Ægir apache include (a2enconf aegir where a conf-available backing file exists; a move back into conf-enabled/conf.d on the deb-installed and Apache 2.2 layouts — the tool probes where the include actually lives) and runs apache2ctl configtest before nginx stops, then hands the daemons back in reverse order and flips the config plane back.

Do not prune the Apache tree during the stage-1/stage-2 window. Provision never deletes it, and it is your way back — so until sign-off, leave /var/aegir/config/server_master/apache*, the apache2 / libapache2-mod-php packages, and /etc/apache2 state all in place.

Timing from the drill (with the hosting-queued daemon running): flip ≈ 65 s, revert ≈ 43 s, re-flip ≈ 54 s. On a cron-dispatch-only box every queued verify waits for the next cron minute, so expect materially longer. If a flip completes with sites differing from baseline, the tool prints the instant daemon-level fallback — paste it exactly as the tool prints it: its middle command re-enables the Apache include in the form your server's layout needs (a2enconf aegir on one layout, a mv back into place on the others), so a snippet copied from this page instead of from the tool could be the wrong one.

Stage 2 — remote adoption

Place aegir2boa-stage2 on both boxes. The verb surface, verbatim from --help:

TXT
Source-resident verbs (vanilla box, root):
  peer       --target <ip>                                  [--live]
  check      --target <ip> [--route per-site|db-import] [--report <env>]
  pre-mig    --target <ip>                                  [--live]
  create     --target <ip> --account <oN> --email <e> --tree <dev|lts|pro>
             [--option EDGE] [--subscr M] [--cores 1]       [--live]
  export     --site <dom>|--all                             [--live]
  transfer   --target <ip> --account <oN> --site <dom>|--all [--live]
  proxy      --target <ip> --site <dom>|--all [--accept-http-diff]
             [--refresh]                                    [--live]
  cert-sync  --target <ip> --account <oN> [--install-cron]  [--live]
  revert     --site <dom>|--all                             [--live]
  resume                                                    [--live]
  status

Target-resident verbs (BOA box, root):
  peer       --source <ip> [--pubkey-file <f>]              [--live]
  import     --account <oN> --route per-site|db-import
             [--site <dom>|--all] [--source-fqdn <fqdn>]
             [--welcome-node]                               [--live]
  import     --account <oN> --revert-db-import              [--live]
  import     --account <oN> --reset-sites                   [--live]
  target-status --account <oN>

The two routes

Stage 2 ships two adoption routes; check decides eligibility from discovery output — the route is never operator-asserted:

  • Per-site adoption (the DEFAULT). Each site is registered natively on the target through the provision import ladder (hosting-import + verify), one at a time. Risk-isolated — one bad site fails alone — with BOA-native node identity from the first write, and it needs none of the db-import reconciliation machinery. Cost: O(n) tasks, and the source frontend history (clients, users, task log) is not carried over.
  • db-import (the validated bridge for homogeneous current-3.x estates only). The whole vanilla hostmaster DB is imported into the fresh account's panel and reconciled with a mandated package of deltas. It preserves frontend history and is estate-size-independent (one DB operation + one reconcile pass instead of per-site task chains). check computes the eligibility triad from the preflight report plus its own enumeration and refuses --route db-import if any leg fails:

    • every platform D7-class,
    • hosting schema_version at the high-water mark,
    • single-box topology (no cluster/pack, no remote web/db servers).

    Two procedural gates ride along: the source must be in nginx mode (a universal check gate, both routes), and the enabled-but-code-absent module list must be acknowledged with check --accept-scrub-listcheck only warns without the flag; transfer is what refuses a db-import run without the recorded acknowledgement. Anything mixed, aged, or unknown takes the per-site route.

Order of operations

TXT
[stage 0/1 done: preflight WARN-or-better for stage 2, box on nginx]
check → pre-mig → create → export → transfer →
import (ON TARGET) → proxy → cert-sync --install-cron

Per-site downtime is the window from a site's export (a 503 goes up) to its proxy swap (now served by the target through the proxy). On the db-import route the frontend-history cutoff is the hostmaster dump timestamp — the queue is paused from pre-mig, so nothing should post-date it.

Source automation is never resumed on the success path. After adoption the source is a proxy shell; its hostmaster must never verify or regenerate vhosts over the proxy files. resume exists only as part of a full-estate revert. The consequence for a long, batched migration: a site still waiting on the source gets no Drupal cron while the dispatcher is paused — size your batches so nothing waits for weeks.

check — route + prerequisites (read-only)

On the source:

SH
aegir2boa-stage2 check --target <target-ip>
# db-import candidates, after reviewing the printed module list:
aegir2boa-stage2 check --target <target-ip> --route db-import --accept-scrub-list

It validates and records, refusing on failure: the newest preflight report under /tmp (root-owned, not world-writable, for this host, stage-2 verdict not FAIL — pass --report <env> to pin one), that stage 1 is actually done (vhost.d populated, nginx-mode type of record), that the current nginx config passes nginx -t, that BatchMode root ssh to the target works, that the target looks like a BOA box, and the target PHP pools.

It also gates database-generation parity: both ends' SELECT VERSION() are read (the target's over ssh, with a MariaDB handshake's 5.5.5- prefix stripped) and recorded into check.env as SOURCE_DB, TARGET_DB and DB_PARITY, and a newer-generation source — MySQL/Percona ≥ 8.0, or MariaDB ≥ 10.6 — is refused against a target below 8.0: its dumps carry collation names the target rejects at import, and on the db-import route the target's own panel tables are dropped before that import runs, so the failure would land with the panel already gone and only undo/a2b-pre-import.sql to come back from. The refusal also blanks any route recorded earlier, so export and transfer cannot ride a stale clean check. A newer-generation source whose target version cannot be read is refused too; any other unreadable or unparseable version records DB_PARITY=UNKNOWN and skips the gate with a named warning — so a check that only warned here has not verified the pairing.

It computes db-import eligibility, enumerates every enabled non-core module on the hostmaster (the scrub review list), grades per-site PHP parity (a D6 site with no php56 pool on the target is flagged and later skipped, not blocking), and records route+target for the following verbs. Re-run check freely — it is always read-only.

pre-mig — pause the source automation

On the source:

SH
aegir2boa-stage2 pre-mig --target <target-ip>          # dry
aegir2boa-stage2 pre-mig --target <target-ip> --live

It comments out the aegir crontab's dispatch line (saved verbatim for resume), stops+disables the hosting-queued daemon (systemd or init.d, prior state recorded), and drains in-flight backend work (up to 10 minutes). From here the estate is operationally frozen: no task dispatch, no queue daemon.

create — build the target account

On the source (it drives the target over ssh):

SH
aegir2boa-stage2 create --target <target-ip> --account o1 \
  --email [email protected] --tree pro                    # dry, then --live

It runs boa in-octopus on the target (defaults --option EDGE, --subscr M, --cores 1), guarded for the window by the target-wide /root/.dont.upgrade.octopus.on.install.cnf flag so neither the install-completing task rounds nor the enforced post-install upgrade can race the migration, then waits for real quiescence (process checks, up to 30 minutes; typically a few). The dry run refuses if /data/disk/<oN> already exists — on a shared box that catches another actor having taken the name. BOA sends its welcome email with the panel/SSH credentials to --email: use the address that should receive them (yours during the window; hand over to the client later).

The install-window guard, end to end. create --live touches /root/.dont.upgrade.octopus.on.install.cnf on the target over ssh immediately before boa in-octopus, and removes it again once the quiescence wait returns — or straight away if in-octopus finished without producing /data/disk/<oN>. While the file exists it is box-wide: any Octopus install started on that target, not just this migration's, skips the hosting-dispatch plus four hosting-tasks --force rounds that normally finish the installation, and skips the enforced post-install octopus up-<tree> upgrade that completes the Let's Encrypt setup. xoct reads the same file remotely and skips its own install-settle wait when it finds it, because with the in-flow upgrade disabled the signal it waits for never arrives — it never writes or removes the marker. Nothing else in BOA creates or erases it, and the hosted normalisation passes do not touch it, so an interrupted create leaves it behind. If a live create was killed, check the target before the next Octopus install on that box:

SH
ssh root@target-ip "rm -f /root/.dont.upgrade.octopus.on.install.cnf"

export — pause + dump each site

On the source:

SH
aegir2boa-stage2 export --all            # dry: per-site plan + skip reasons
aegir2boa-stage2 export --all --live
# or one site at a time: --site example.com

Per site, live: it captures the HTTP baseline, raises the 503 stub — a shadow server block in nginx pre.d/ that wins over the real vhost (first-defined wins), leaving the original vhost file untouched; nginx -t-gated with automatic stub removal on failure — then dumps the site's DB with the site's own credentials from its drushrc (no root DB access is ever needed on the source) into /var/aegir/src/a2b/, and writes the site's manifest. It skips honestly, per site: missing vhost or alias paths, a multi-host DB, unparsable credentials, a 443 vhost whose cert files are missing, or insufficient dump headroom. A failed dump leaves the stub up (data consistency over uptime) — revert --site <dom> unpauses it.

On the db-import route the estate export also dumps the hostmaster DB and puts the source panel into maintenance mode, so nothing post-dates the frontend snapshot. That dump is accepted only if the dumper exits cleanly AND the file ends with its own completion marker — a dump that died after its header is refused and the estate is NOT marked exported, so the failure surfaces at export time rather than as a broken panel after the import (this export is what the whole adoption is rebuilt from).

transfer — ship everything to the target

On the source:

SH
aegir2boa-stage2 transfer --target <target-ip> --account o1 --all   # dry, then --live

It measures total size against the target's free /data/disk space (refuses without need + 500 MB), then rsyncs each platform tree to /data/disk/<oN>/static/a2b/<platform>/ chowned to the account (a source-uid tree is unreadable to the account and breaks every later import), each site dump + manifest to /data/disk/<oN>/src/a2b/, plus the ssl.d trees and the nginx configs as reference copies (never into the target's live config — vhosts are regenerated natively by verify tasks). Drush aliases are deliberately not transferred on either route: vanilla aliases carry /var/aegir roots that would poison the target; everything is regenerated fresh. The route marker ships on every transfer; on the db-import route it additionally ships hostmaster.sql, the acknowledged scrub list, and the source FQDN record (transfer refuses a db-import run without a check --accept-scrub-list recorded).

import, per-site route

On the target:

SH
aegir2boa-stage2 import --account o1 --route per-site --all   # dry, then --live
# or per site: --site example.com

For the window it sets hosting_platform_automatic_site_import = 0 (restored at the end) — a platform verify would otherwise auto-import every sites/ dir it finds, colliding with the explicit imports. Then, per platform: provision-save a platform_a2b_<name> context, create the platform node, and verify it (the verify registers the install-profile package — a hard prerequisite for site imports). Per site: create its DB and user with the site's own credentials (settings.php keeps working unmodified; refuses to overwrite an existing DB), provision-save the site context, hosting-import it and drive the import task (inline when re-running against an already-registered node — a retry quirk the drill caught), map its PHP version in the account's multi-fpm.info and wait for the pool socket (a D6 site without its socket is a per-site FAIL; the site must not serve under the account default), wait the chained verify, probe HTTP against the source baseline, and re-enable the site's Drupal cron — adopted sites land with cron dark by BOA design, and the source dispatcher that used to run it is paused, so leaving it dark means silent job loss. One site's failure never blocks the next; failed sites are listed and stay un-adopted.

import, db-import route

On the target:

SH
aegir2boa-stage2 import --account o1 --route db-import        # dry, then --live
# optional: --welcome-node to recreate a public frontpage

The dry run prints the full numbered plan. The live run opens the account freeze window: the freeze marker (log/proxied.pid) makes every nightly/periodic BOA agent skip the account, in-flight nightly passes are drained, and the account's task dispatcher is held aside for the duration — a raw DB import must never race task dispatch. If the import fails mid-way the dispatcher deliberately stays held (a broken panel must not dispatch); only success, --revert-db-import, or manual repair restore it. The steps, each idempotent behind its own marker:

  1. Snapshot: dump the fresh panel DB to undo/a2b-pre-import.sql (the revert point) and capture the enabled-module set into undo/a2b-enabled-baseline.txt (the reconciliation source of truth). The import pre-checks roughly 2× the transferred hostmaster dump + 200 MB free under the account root for this snapshot — an extra headroom gate on top of the transfer one.
  2. Drop, then load with sandbox strip: the target panel DB is dropped (an overlay import would leave orphaned tables) and the transferred dump streamed in minus the MariaDB ≥ 10.5.25 sandbox header line, which the Percona client rejects.
  3. Queue hygiene + neutering: stale/pending imported tasks are failed-out so nothing dispatches from the source's queue, and client welcome emails are switched off — the imported DB holds real client addresses.
  4. Module scrub (acknowledged rows only): enabled modules whose code is absent from the panel platform are removed from {system} — but only modules on the check-time acknowledged list; anything else aborts the import for review. On a standard vanilla estate this list is empty or tiny (BOA's panel platform carries code for everything a stock 3.x enables).
  5. Reconcile: platform paths are re-pointed at the landed trees, the BOA module set is re-enabled from the snapshot baseline, updatedb runs (a no-op at schema high-water), features are reverted to code state, and per-site Drupal cron intervals are seeded hourly — vanilla's cron table is schema-less, so imported sites would otherwise have cron silently off forever.
  6. Service restore: the Let's Encrypt service row (absent from a vanilla server node) is re-attached; optionally a welcome frontpage node (--welcome-node) — by default the imported admin-only frontpage stands.
  7. Per-site content DBs are created and loaded with each site's own credentials.
  8. Identity surgery — targeted UPDATEs by nid, never a blanket rename. The imported server node's title becomes the target FQDN (Ægir regenerates remote_host from that title on every server verify — a stale value would silently re-point the account at the old box over ssh); the panel site node adopts the account's own panel domain (<oN>.<target-fqdn>, the identity already on disk); the imported panel platform is re-pointed at the real one; the imported server_localhost node gets the target account's own DB credentials restored from the snapshot (the imported node carries the source's — its verify would otherwise poison the account alias with a dead DSN; this failure is FATAL). History rows keep old FQDN references by design and are reported, not rewritten.
  9. Inline verify ladder, while the dispatcher stays held: server verifies (with an assert that remote_host regenerated to the target FQDN), platform verifies, the panel site verify (FATAL on failure), then per site: PHP mapping, verify, and an HTTP probe against the source baseline. FPM socket waits are deferred until after the freeze lifts — the pool agent skips frozen accounts by design.
  10. Close: panel bootstrap probe (FATAL if the panel does not bootstrap), stray cascade-queued tasks neutered, dispatcher restored, freeze lifted, deferred FPM waits + re-probes, done-marker written.

proxy — cut each site over

On the source:

SH
aegir2boa-stage2 proxy --target <target-ip> --all      # dry, then --live
# per site: --site example.com; accept a changed-but-2xx/3xx answer:
#   --accept-http-diff

Per site, gated on the target actually answering for that site (an HTTP probe against the manifest baseline; a differing 2xx/3xx needs --accept-http-diff, anything else is a SKIP). The gate is not status-only: before the per-site probes the tool fingerprints what the target answers for an impossible hostname, and refuses any site whose 200 response body is byte-identical to that fingerprint — a BOA box answers an unknown Host with its "Under Construction" catch-all, so a 200 alone proves nothing. That refusal is unconditional: --accept-http-diff tolerates a different status but can never accept a target that is not serving the site at all; hitting it means the import did not produce a serving vhost for that site. Then: the real vhost is moved aside to the dotfile .<domain> (the revert artefact) and a proxy vhost pointing at the target is written in its place; for https sites a per-site proxy cert store is seeded from the site's existing cert files and an https proxy vhost added. nginx -t-gated: on failure the swap is undone and the 503 stub keeps pausing the site. On success the stub is removed — the site now serves live from the target through the source proxy.

proxy --all also puts the old panel domain behind a permanent 503 (both routes): the panel identity moved to <oN>.<target-fqdn>, so the old panel URL must not proxy anywhere. Tell the client the new URL.

The proxy templates are embedded, vanilla-adapted equivalents of BOA's own proxy vhosts (BOA's originals need the BOA nginx build and Octopus cert paths, so they cannot be dropped onto a distro nginx verbatim). The emitted https vhost also adapts its HTTP/2 syntax to the source's own nginx version — the standalone directive only where that nginx knows it, the listen-parameter form otherwise — because a vanilla box runs the distribution build, not BOA's. The catch-all location also forwards /.well-known/acme-challenge/ — so the target can mint and renew real Let's Encrypt certs for domains whose DNS still points at the source, for the whole proxy window.

Proxy vhosts carry the vanilla vhost's whole server_name set plus www.<domain>, even when the vanilla vhost never served it. BOA's Let's Encrypt requests a SAN certificate for the bare name AND www. by default, and the adopting BOA box answers www. itself — but the ACME challenge for it arrives at THIS box, and without the alias it lands on the catch-all and the whole certificate order fails, leaving an empty certificate. A name the client's DNS does not resolve simply never arrives, so the extra alias is inert on estates without www. records.

proxy --refresh (dry, then --live) re-renders the proxy vhost(s) of already-proxied sites in place, from the dotfile original plus the CURRENT templates — for when a template fix must reach a live proxy window without the revert → re-export loop. No markers move, the dotfile is untouched, and the previous conf is restored if nginx -t fails. It skips any site that is not proxied, and refuses when the revert dotfile is missing.

cert-sync — keep the proxy's certs fresh (https estates)

On the source:

SH
aegir2boa-stage2 cert-sync --target <target-ip> --account o1 --install-cron        # dry
aegir2boa-stage2 cert-sync --target <target-ip> --account o1 --install-cron --live

For each proxied https site it pulls the target account's live LE cert pair into the source's proxy cert store and reloads nginx (rollback to the previous pair if nginx -t fails). --install-cron writes /etc/cron.weekly/a2b-cert-sync so a months-long proxy window never serves an expired certificate — nobody renews on the proxy box; it mirrors the box that does. Remove that cron at decommission (stage 3).

The target does not obtain its own certificates for you. Adopted sites arrive dark by design — cron and Encryption both off — so the target holds NO certificate for a site that is live on HTTPS today. Nothing looks wrong while you are in the window, because the proxy is serving the source's certificates; it becomes an outage the moment DNS moves. Enable Encryption per site in the target panel and let Let's Encrypt issue before you repoint anything — ACME reaches the target through this proxy, so issuance works while DNS still points at the old box. cert-sync refuses to report success while any proxied HTTPS site has no certificate on the target: it names each one and exits non-zero. Enable and verify sites one at a time — back-to-back verifies can collide on the LE tooling's per-account lock; re-run the verify if a run reports a lock abort.

db-import route nuance. Sites adopted per-site are registered fresh on the BOA panel, whose defaults add the www. alias — so their vhosts answer the www. ACME challenge. The db-import route imports the vanilla panel as-is, and vanilla adds no automatic www. alias, so a default SAN issuance fails its www. challenge on the target's catch-all. Before enabling Encryption on a db-import-adopted site, either add its www. alias in the target panel, or request a bare-name certificate by creating the empty control file <oN>/static/control/ssl-no-san-<domain>.info.

Reverts — the way back, until DNS moves

Everything here is drilled. The one rule that matters: a reverted site must be re-exported before any retry — the moment it serves locally again its transferred dump is stale, and a cutover from a stale dump silently loses writes. The tools enforce this by clearing the export and transfer markers on revert.

Single site, on the source:

SH
aegir2boa-stage2 revert --site example.com          # dry, then --live

It restores the original vhost (the dotfile back over the proxy vhost), removes the https proxy vhost and the 503 stub, and reloads nginx. The site serves locally again as if nothing happened. It does not touch the target copy — reset that before any retry (below).

Whole estate: revert --all --live, then resume --live (restores the crontab dispatch line and the hosting-queued daemon exactly as recorded, and clears the source panel's maintenance mode). resume refuses while any site is still paused or proxied.

On the target:

SH
aegir2boa-stage2 import --account o1 --reset-sites --live        # per-site route
aegir2boa-stage2 import --account o1 --revert-db-import --live   # db-import route

--reset-sites drops exactly the site DBs this tool loaded (marker-fenced — it can never touch a DB it did not create). --revert-db-import additionally restores the panel DB from the pre-import snapshot, removes the estate's now-ownerless vhosts — exactly the manifest URIs' files under the account's config/server_master/nginx/vhost.d, so the account's own panel vhost is untouched — reloading nginx (nginx -t-gated) if any were removed, and returns the held dispatcher. Both print the same reminder: a retry needs a fresh export + transfer.

Removing those vhosts is what keeps the retry path safe: the restored panel no longer knows those sites, so nothing owns their vhosts any more, and a later re-adoption of the same names would lose the server_name conflict to the dead vhost by include order — serving 500s while every marker looks healthy.

Monitoring a migration

SH
aegir2boa-stage2 status                       # on source: route, markers, per-site [P E T X] + live HTTP
aegir2boa-stage2 target-status --account o1   # on target: route, freeze state, step markers

Source state lives under /var/aegir/log/a2b/ (state, manifests, per-site markers), target state under /data/disk/<oN>/log/a2b/; dumps under src/a2b/ on both sides; the pre-import snapshot under /data/disk/<oN>/undo/. A crashed run's stale lock (/var/run/aegir2boa-stage2.*.lock) is taken over automatically once its recorded pid is dead. Per-site dumps use mysqldump (not mydumper) with each site's own credentials, and every remote action is plain root ssh + rsync. Locks and markers are scoped per account+site, so one source can migrate into two target accounts sequentially — but never run two acting verbs on the same scope concurrently.

Stage 3 — DNS cutover and source decommission

The proxy window ends when DNS points every domain at the target and the old box is retired. Until DNS moves, the per-site dotfile revert exists; after it, the target's own backups are the safety net. Take this deliberately:

  1. Pre-lower TTLs. The preflight recorded the estate's DNS TTLs; get every A/AAAA record's TTL down (300–3600 s) at least one old-TTL period before the cutover, so the switch propagates fast.
  2. Repoint DNS for every migrated domain to the target IP. Site-by-site or estate-wide — the proxy keeps covering stragglers either way, so this is not a downtime event.
  3. Mail and network identity. A new IP invalidates any SPF ip4: record pinned to the old box and needs PTR/rDNS set up at the new provider; if the old box received mail (MX) or ran authoritative DNS (BIND/hosting_dns), move those roles explicitly — the migration tools do not.
  4. Re-home the jobs that watched the old box: monitoring/alerting, external backup jobs, anything in root's crontab an operator added. The source's Ægir automation is already paused; leave it paused.
  5. Verify traffic has left the proxy. Watch the source's nginx access logs go quiet per domain (residual hits mean stale DNS caches or overlooked records), and spot-check dig +short <domain> against the target IP.
  6. Retire the proxy. Host policy decides the window (days to months). When it closes: remove /etc/cron.weekly/a2b-cert-sync, archive what the record needs (the preflight .txt/.env, /var/aegir/log/a2b/, the migration log), snapshot the box if the provider makes that cheap, then power it off.
  7. Clean the target: remove the source's CSF allow/ignore lines (csf -ra after), drop the migration key from /root/.ssh/authorized_keys, and optionally the tool copy and the landed src/a2b/ artefacts once the estate has run clean past a backup cycle.

Troubleshooting quick reference

Symptom Meaning / action
check dies: no preflight report / wrong host run aegir2boa-preflight on this box now (reports are per-host and die with /tmp)
check dies: stage-2 verdict FAIL resolve the named reasons; re-run the preflight
check dies: current nginx config fails nginx -t fix the box first — the tool refuses to build on a broken config
check dies: source DB is a newer generation than the target the source reports MySQL/Percona ≥ 8.0 or MariaDB ≥ 10.6 and the target reports a major version below 8 — its dumps carry collation names the target rejects at import; use a Percona 8.4 target for this source. The refusal also blanks any route recorded by an earlier check, so re-run check once the target is right
check dies: target DB version unreadable, source provably newer the same gate refuses rather than fails open — fix the target probe (see /var/log/aegir2boa-stage2.log), then re-run check
--live refused: no prior clean dry run run the dry form of the same verb+scope first (every failed live consumes the token)
create waits forever another Octopus operation on the target; it times out at 30 min with a warning — verify quiescence manually before import
a later Octopus install on the target skips its post-install upgrade a killed create left /root/.dont.upgrade.octopus.on.install.cnf on the target and nothing erases it automatically — rm -f it there, then re-run that install's upgrade
export skips a site the printed reason (missing vhost/alias, multi-host DB, bad creds, missing cert files, headroom); fix or accept, re-run
dump fails mid-export the 503 stub STAYS up (consistency over uptime); revert --site <dom> to unpause
import (db-import) fails mid-run the dispatcher stays held on purpose; fix and re-run import (steps are idempotent), or --revert-db-import
site fails its verify/probe on the target it is listed and skipped; proxy will refuse it (probe gate) — fix, re-import that site
proxy skips a site: HTTP diff the target answers differently than the baseline; re-check the site on the target, or --accept-http-diff if the change is expected
proxy skips a site: CATCH-ALL page the target is not serving that site at all — its 200 is the box's own "Under Construction" answer; re-check the site's import. --accept-http-diff cannot override this
FPM socket wait times out the pool agent needs a pass (runs every few minutes; frozen accounts are skipped until the freeze lifts); D6 sites must not be proxied without their pool
lock held another run of the same verb+scope is live; stale locks self-clear once the pid is dead
anything else read /var/log/aegir2boa-stage2.log, then the named hostmaster task log (node/<nid> on the panel)

© 2026 BOA Documentation. All rights reserved.