A user fills in a form: environment, size, disks, networks, tags. Some time later there’s a Windows Server 2025 VM with:

  • a sequential hostname, allocated from Active Directory (AD);
  • 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 there’s none of the automation tooling left on the box. The build behaves like the ideal house guest: it makes itself useful, tidies up and leaves.

This three-part series is how that works. It’s a VM Apps build: a classic Aria Automation cloud template plus vRO (Aria Automation Orchestrator). It’s also 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

Three layers: Aria Automation and vRO allocate the hostname and pass placement data into the guest; cloudbase-init sets the network, the disks and the domain join and pulls the engine across two reboots; the file-share engine stages, installs, validates, reports and removes itself Each layer owns what only it can do, and no vCenter or AD-admin credential ever enters the guest.

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, and 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 that everything after it runs in. That last one is the whole reason the next layer exists (more on that below).
  • The engine is pulled, not embedded. Installers change and agents get new versions, and re-releasing a cloud template for every payload update is how automation dies. Update a script on the share, and every build after that 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: a sequential hostname. The template builds a prefix from the location, classification, environment and 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, and deleted machines can be less gone than we’d like. The old name may still live in DNS, a backup catalogue or the configuration management database (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, and which portgroup each NIC is on. It writes all that 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 security operations centre (SOC) and the requester. (A note for part 3: the requester’s “your deployment has completed” email 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. It holds one cloud-config part (set the hostname, write the tags to disk) and four #ps1_sysnative scripts, run in order. Three conventions make them safe to re-run (idempotent):

  • Flag files under Flags\. A script exits straight away if its flag exists, so re-runs after a reboot do nothing.
  • Transcripts under Logs\, one per script.
  • Exit 1003 means “reboot me and run this part again on next boot” (the flag then short-circuits it). Exit 0 means done. Exit 1 means 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

That’s two reboots before a single agent has been installed. By Windows standards, it’s barely clearing its throat.

The network script carries an assumption worth writing on the wall. It pairs OS adapters with the request’s NICs by position. That holds for freshly cloned VMs where the NICs were added in PCI order. If anyone ever changes the NIC order after cloning, 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 sets the hostname and IP and joins the domain. Then one cloudbase-init userdata installs the agents and 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, unmanaged Windows install. That means the local Administrator, the default execution policy, and no central policy.

At the join, the machine lands in its target organisational unit (OU). On the next policy refresh, which the reboot guarantees, Group Policy applies. In this environment, the policy for member servers includes the usual security baseline:

  • controls on script execution;
  • limits on what may run, from where, and under which accounts;
  • hardening of the local administrator context.

None of that is negotiable, and none of it should be. It’s 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, running scripts from its own directory. Before the join, that context can do anything. After the join, it’s exactly the kind of context the baseline is designed to restrict.

So everything that comes after the join is in trouble: the installer pulls, the agent installs, the reboots, the validation. They 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. The log was technically correct, which is the least useful kind of correct.

Two ways out, one of them wrong. You can relax policy for the build: a staging OU with a weaker baseline, a Group Policy Object (GPO) exemption for the cloudbase-init path, or a delayed join. But then 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. Each task runs as a named account: a domain-joined local admin for the pull and the join check, and SYSTEM for the build master. It runs at the highest run level, with -ExecutionPolicy Bypass on each call, from a staging path the policy allows. That’s an ordinary, auditable pattern, and the baseline was written to allow for it.

So cloudbase-init doesn’t run the OS commands itself; it hands them to tasks. Its 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. Letting it and cloudbase-init own the same settings means two code paths fighting (hence customizeGuestOs: false).

Driving the guest from outside means vRO calling into the VM for two hours. That needs guest credentials held centrally, and it keeps a management path open for the whole build. ABX (Aria’s action-based extensibility) can’t reach inside the guest at all.

The platform’s job is what needs platform credentials: the hostname from AD and 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 the code baked into the template 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. It holds the share path and accounts, the image, the project, deployment and requester, the installer folder and file pairs, the tags and the placement facts.
  3. Encrypt the two share passwords with AES, using a key derived from the machine’s BIOS UUID. Copied off the box, the payload is useless.
  4. Generate a runner, and run it through a one-shot task as local admin. It maps the share read-only, copies 04-stage-payloads, 05-build-master and 06-validate-build locally, unmaps, and launches staging.
  5. Write the flag and exit 1003. Reboot 2. cloudbase-init’s job is done, though nobody has mentioned the uninstall to it yet.

04-stage-payloads copies every installer to local disk. Installers never run across SMB, so they’re immune to network blips and file locks mid-install. It also 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. Leaving out 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

Each of those 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 goes into a catalog, and a domain-joined server with its agents comes out. The security team’s controls (naming, join, hardening, monitoring agents) apply every time, because they’re in the pipeline, not in a checklist.

The separation of concerns is what keeps it maintainable. The platform holds the credentials and the guest does the work. A change to the software stack is a script update on a share, not a template re-release.

Rules learned

  • Split by who holds the credential. The platform queries AD and vCenter; the 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 and exit 1003 make first-boot scripts idempotent and reboot-safe.
  • 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.

Broadcom documentation

Part 2: the state machine that survives four reboots.


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