A user fills in a form: environment, size, disks, networks, tags. Some time later there’s a Windows Server 2025 VM that has a sequential hostname allocated from Active Directory, static IPs on up to four NICs, its data disks laid out and lettered, domain membership, the standard agent stack installed and healthy, a pixel-perfect HTML build report on a file share — and none of the automation tooling left on the box.

This three-part series is how that works. It’s a VM Apps build (classic Aria Automation cloud template plus vRO), and it’s a good example of the pattern: the platform does the allocation and the metadata; the guest does the guest. Part 1 is the architecture. Part 2 is the state machine that survives four reboots. Part 3 is the validation report and the details that hurt.

Customer-specific names, products and policies are generalised throughout. The patterns are the point.

Three layers, three reasons

┌──────────────────────────────────────────────────────────────────────┐
 1. Aria Automation + Orchestrator         (control plane)            
    cloud template · Event Broker  vRO at Allocation / Post-Provision 
    hostname from AD · placement metadata  guestinfo · notifications  
├──────────────────────────────────────────────────────────────────────┤
 2. cloudbase-init                          (first boot, template-owned)
    multipart userdata: network · disks · domain join · pull engine    
├──────────────────────────────────────────────────────────────────────┤
 3. File-share build engine                 (guest state machine)      
    stage payloads · install across reboots · validate · report · self-
    destruct                                                           
└──────────────────────────────────────────────────────────────────────┘

Each seam exists for a reason you can state in one sentence:

  • Aria/vRO owns what needs platform credentials — querying AD for the next hostname, reading vCenter for where the VM landed. The guest never holds a vCenter or AD-admin credential.
  • cloudbase-init owns what must happen before anything else — the network has to work before the share can be reached; the disk layout has to exist before installers land on it; and the domain join changes the security context everything after it runs in, which is the whole reason the next layer exists (more on that below).
  • The engine is pulled, not embedded — because installers change, agents get new versions, and re-releasing a cloud template for every payload update is how automation dies. Update a script on the share; every subsequent build gets it.

vCenter guest customization is disabled (customizeGuestOs: false). One code path owns hostname, networking, disks and join; two would fight.

The control plane: two vRO hooks that matter

Compute Allocation → sequential hostname. The template composes a prefix from location/classification/environment/application inputs. A vRO workflow queries AD computer objects with that prefix and allocates highest existing suffix + 1. Gaps are never reused: with -001, -002, -003 and -005 present, the next is -006. A gap usually means a deleted machine whose name may still live in DNS, a backup catalogue or the CMDB; reusing it is how you restore the wrong server.

Compute Post Provision → placement metadata into the guest. A second workflow asks vCenter where the VM actually landed — vCenter, datacenter, cluster, host, folder, datastores, NIC→portgroup map — and writes it as JSON into the VM’s extraConfig as guestinfo.vra.infrastructure. Later, inside the guest, vmtoolsd.exe --cmd "info-get guestinfo.vra.infrastructure" reads it back. That’s the bridge that lets the build report say “this VM is on host X, datastore Y” with zero vCenter credentials in the guest.

Three more Post Provision workflows email the backup team, the SOC and the requester. (One observation for part 3: the requester’s “your deployment has completed” fires here — before the in-guest build has even started.)

First boot: cloudbase-init, four scripts, two reboots

The template’s cloudConfig is a MIME multipart: one cloud-config part (hostname + write the tags to disk) and four #ps1_sysnative scripts, executed in order. Three conventions make them idempotent:

  • Flag files under Flags\ — a script exits immediately if its flag exists, so re-runs after a reboot are no-ops.
  • Transcripts under Logs\, one per script.
  • Exit 1003 = “reboot me and run this part again on next boot” (the flag then short-circuits it). Exit 0 = done. Exit 1 = failure.
ScriptDoesExit
00-config-networkpairs adapters (by ifIndex) with to_json(self.networks) (by deviceIndex); static IP/gateway/DNS per NIC1003 — reboot 1
01-config-disksextends C:, onlines/initialises/partitions/formats each extra disk from the request array0
02-join-domainruns the join script via a one-shot scheduled task as local admin; verifies CsDomain ≠ WORKGROUP0
03-init-pullerwrites data-payload.json, encrypts the share secrets, pulls 04/05/06 from the share, launches staging1003 — reboot 2

The network script is the one with an assumption worth writing on the wall: it pairs OS adapters with the request’s NICs positionally, which is valid for freshly cloned VMs where NICs were added in PCI order. If anyone ever customises NIC order post-clone, revisit it.

Why not just cloudbase-init (or guest customization) all the way down?

The obvious design is the one we started with: let the platform do the guest. vCenter guest customization for hostname, IP and domain join, then one cloudbase-init userdata that installs the agents, patches, and reports back. No scheduled tasks, no pulled engine, no state machine. It works on a workgroup machine. It stops working the moment the machine joins a real domain, and it stops in a way that is easy to misread.

The domain join changes the rules mid-build. Everything up to the join runs as a fresh, local, un-managed Windows install: local Administrator, default execution policy, no central policy. At the join the machine lands in its target OU, and on the next policy refresh (which the reboot guarantees) Group Policy applies. In this environment the policy set for member servers includes the usual security baseline: script-execution controls, restrictions on what may run from where and under which accounts, and hardening of the local administrator context. None of that is negotiable, and none of it should be — it is the same policy every production server gets.

What that does to a first-boot pipeline. cloudbase-init runs its plugins as a service, as LocalSystem, executing scripts from its own directory. Before the join that context can do anything. After the join it is exactly the kind of context the baseline is designed to constrain, so the parts of the build that come after the join — the installer pulls, the agent installs, the reboots, the validation — either fail outright or, worse, quietly do nothing: cloudbase-init logs the plugin as executed, the script never ran anything, and the build “completes” with an unpatched server carrying no agents. The first few builds looked exactly like that.

Two ways out, one of them wrong. You can relax policy for the build (a staging OU with a weaker baseline, a GPO exemption for the cloudbase-init path, a delayed join) — which means the server is built under one set of rules and delivered under another, and the join becomes a late step that nothing after it exercises. Or you can accept the policy as the environment it is, and run the post-join work in a context the policy permits.

The permitted context is a scheduled task under an explicit identity. A task registered to run as a named account — a domain-joined local admin for the pull and the join verification, SYSTEM for the build master — at highest run level, with -ExecutionPolicy Bypass per invocation, from a staging path the policy allows, is an ordinary, auditable pattern that the baseline was written to accommodate. That is why the OS commands aren’t run by cloudbase-init but delegated to tasks: cloudbase-init’s job shrinks to “get the network up, lay out disks, join, hand off”. The design also picks up three things it now depends on:

  • a per-task execution ceiling (1 h for the pull, 2 h for the build) that contains a hung installer instead of leaving a half-built VM;
  • an at-startup trigger, which is what makes a multi-reboot state machine possible after cloudbase-init has been uninstalled;
  • a clean security story: the identities that do the work are the ones the domain already governs, and they stop existing on the box when the build is done.

Why not Aria’s own in-guest mechanisms? Guest customization only covers hostname, IP and join, and having it and cloudbase-init own the same settings means two code paths fighting (hence customizeGuestOs: false). Driving the guest from outside — vRO calling into the VM for two hours — needs guest credentials held centrally and keeps a management path open for the whole build; ABX can’t reach inside the guest at all. The platform’s job is what needs platform credentials: the hostname from AD, the placement facts from vCenter. The guest does the guest, under the domain’s rules, from the first reboot after the join.

Even if the GPO were relaxed tomorrow, don’t simplify the wrappers away: the ceiling and the startup trigger are worth having on their own.

The hand-off: 03-init-puller

This is where template-embedded code stops and the centrally managed engine starts:

  1. Create Flags\ Logs\ Data\ Scripts\ under the log folder.
  2. Write data-payload.json — the one document everything after this reads: share path and accounts, image, project/deployment/requester, the installer folder/file pairs, the tags, the placement facts.
  3. Encrypt the two share passwords with AES, key derived from the machine’s BIOS UUID. The payload is useless copied off-box.
  4. Generate a runner, execute it via a one-shot task as local admin: map the share read-only, copy 04-stage-payloads, 05-build-master, 06-validate-build locally, unmap, launch staging.
  5. Write the flag, exit 1003. Reboot 2. cloudbase-init’s job is done.

04-stage-payloads copies every installer to local disk (installers never run across SMB — immune to network blips and file locks mid-install) and registers the Build-Master startup task. From here on, every boot runs 05-build-master.ps1 until the build is complete.

What the requester actually sees is a short form. Stripped of the site-specific enum values, the inputs are:

inputs:
  location:        # site code -> hostname prefix
  classification:  # security zone -> hostname prefix, OU
  environment:     # prod / pre-prod / test -> hostname prefix, tags
  application:     # application code -> hostname prefix, folder
  image:           # Windows2025 (the template is image-versioned)
  flavor:          # Small / Medium / Large -> vCPU + RAM
  count:           # number of identical machines
  bootDiskSizeGB:  # C: (extended in-guest by 01-config-disks)
  primaryNetwork:  # required
  network2..4:     # optional; each becomes a static NIC
  additionalDisks: # [{number, name, letter, sizeGB}] -> D:, L:, ...
  tags:            # free-form key/value -> written to disk, shown in the report

Every one of those either shapes the hostname, lands in guestinfo, or is written to disk for the build engine to read. Nothing is entered twice.

Why this matters outside the lab

For an organisation, this pipeline turns a Windows server from something a person builds into something the platform delivers: a request in a catalog, a domain-joined and agent-loaded server out, with the security team’s controls (naming, join, hardening, monitoring agents) applied every time because they’re in the pipeline, not in a checklist. The separation of concerns is what makes it maintainable — the platform holds the credentials, the guest does the work, and a change to the software stack is a script update on a share rather than a template re-release.

Rules learned

  • Split by who holds the credential: platform queries AD/vCenter, guest never does. guestinfo is the one-way bridge.
  • Disable vCenter customization when cloudbase-init owns the guest. One owner.
  • Pull the engine from a share; embed only what must run before the network exists.
  • Flag files + transcripts + exit 1003 = idempotent, reboot-safe first-boot scripts.
  • Anything after the domain join runs in a scheduled task under an explicit identity — for policy, for the execution ceiling, and for the startup trigger.
  • Never reuse hostname gaps.

Part 2: the state machine that survives four reboots.


Lab write-up of a production pattern; customer specifics removed. Opinions my own.