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:
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:
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-importrestored 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 — andvendor/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 --livemirroring 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 thewww.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/php56on 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 —
checkflagging 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
--liveto act.--liveis 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
apache2layout. 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 toupdate-rc.d), which of the three routes the Ægir apache include takes (conf-availablebehinda2enconf, a bareconf-enabledsymlink with no backing file, or 2.2'sconf.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,--statusincluded, and stops atno 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 runaegir2boa-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 updateis not fatal — it is named in the log (stale/EOL apt sources?, pointing atarchive.debian.org/old-releases.ubuntu.com) and the install proceeds against cached lists — but if the packages then cannot be installed, stage 1 aborts withnginx install failedorphp-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
peerverb rather than by hand. The target's lfd reads anssh-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:
# 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, andcheckrefuses a report from a different host. Re-run the preflight after any reboot, and copy the.txt/.envpair 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;
checkgrades this per site and later skips a site whose pool is missing rather than blocking the run). - Disk headroom: per site roughly 2× its DB size free under
/var/aegiron the source for dumps; the whole estate + 500 MB free under/data/diskon the target (transfermeasures 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
oNaccount 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:
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:
/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:
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_ssl → nginx_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:
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).
checkcomputes the eligibility triad from the preflight report plus its own enumeration and refuses--route db-importif any leg fails:- every platform D7-class,
hostingschema_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
checkgate, both routes), and the enabled-but-code-absent module list must be acknowledged withcheck --accept-scrub-list—checkonly warns without the flag;transferis what refuses a db-import run without the recorded acknowledgement. Anything mixed, aged, or unknown takes the per-site route.
Order of operations
[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:
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:
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):
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:
ssh root@target-ip "rm -f /root/.dont.upgrade.octopus.on.install.cnf"
export — pause + dump each site
On the source:
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:
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:
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:
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:
- Snapshot: dump the fresh panel DB to
undo/a2b-pre-import.sql(the revert point) and capture the enabled-module set intoundo/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. - 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.
- 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.
- Module scrub (acknowledged rows only): enabled modules whose code is absent
from the panel platform are removed from
{system}— but only modules on thecheck-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). - Reconcile: platform paths are re-pointed at the landed trees, the BOA module
set is re-enabled from the snapshot baseline,
updatedbruns (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. - 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. - Per-site content DBs are created and loaded with each site's own credentials.
- Identity surgery — targeted UPDATEs by nid, never a blanket rename. The
imported server node's title becomes the target FQDN (Ægir regenerates
remote_hostfrom 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 importedserver_localhostnode 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. - Inline verify ladder, while the dispatcher stays held: server verifies (with
an assert that
remote_hostregenerated 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. - 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:
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:
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:
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:
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
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:
- 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.
- 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.
- 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. - 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.
- 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. - 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. - Clean the target: remove the source's CSF allow/ignore lines (
csf -raafter), drop the migration key from/root/.ssh/authorized_keys, and optionally the tool copy and the landedsrc/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) |
Related
- Cross-host migration — xoct, xcopy, xmass — the BOA-to-BOA move this path is modelled on; reach for it when both boxes are already BOA.
- Firewall blocked (CSF/lfd) — clearing a port-22 temp block, relevant to the pre-key-exchange CSF whitelist.
- Security & isolation — CSF,
ip_access, and the realip layer a migration proxy interacts with. - See the Reference appendix for the consolidated variable, command, and control-file tables.