Skip to content

Self-Service Test VMs

Developers can spin up their own EC2 or Azure test machines to run playbooks against — no shared Terraform edits — using the aws-test-vm and azure-test-vm Coder templates. Each workspace is one VM, created and destroyed per developer, and reclaimed automatically. The sections below describe AWS; Azure differences are summarized in their own section.

Creating a test VM (AWS)

In Coder, create a workspace from aws-test-vm and pick:

Parameter Notes
Operating System RHEL 9 (default, matches the managed fleet), Ubuntu 24.04, or Windows Server 2022 / 2025 (t3.large recommended — 4 GB is tight for Windows). Changing it later replaces the VM.
Instance Type t3.medium / t3.large / t3.xlarge — resizable in place across a stop/start.
Root disk size Default 40 GB. Grow-only (EBS can't shrink); growth applies in place and the filesystem expands on next boot.
Application tag The instance's application tag for cost/inventory attribution. Defaults to odb; editable per workspace.
Data disks (YAML/JSON) Extra volumes as a YAML (or JSON) map, same structure as the production Terraform disks blocks — each key becomes the volume's ansible_key tag, exactly like production, so tag-driven disk discovery in roles works identically. Volumes attach raw (no partition table, no filesystem). Grow-only per disk; removing a key destroys that volume. Example: epic: / device_name: /dev/sdc / size_gb: 50
Hostname Optional short inventory name (e.g. nginx1nginx1.sapphire.dev). Empty = auto test-<owner>-<workspace>. Keep custom names unique across the team.
Coder agent On by default. Gives workspace health and "Last used" tracking. Note: the UI terminal and coder ssh ride the agent's relay connection, which can't traverse the proxy from this network position (upstream Coder limitation) — health/metadata always work, but expect the interactive buttons not to connect; use SSH (Linux) or RDP/WinRM (Windows, below) instead. Installs the agent on the box, so turn it off when you want the target pristine.

With the agent disabled the workspace shows "agent timeout" — this is expected: the machine is exactly what a playbook target should be, untouched.

NVMe device naming

On the t3/m5 instance families the raw data disk attaches at /dev/sdf but enumerates as /dev/nvme1n1. Have roles discover disks by pattern or /dev/disk/by-id rather than hardcoding sdf — good practice for customer environments anyway.

Running playbooks against it

The dynamic inventory picks the VM up automatically — its tag:Name (test-<owner>-<workspace>) becomes the inventory hostname. From any dev workspace:

ansible test-<owner>-<workspace>.sapphire.dev -m ping
ansible-playbook --limit 'test-<owner>-*' playbooks/<name>.yml

The workspace metadata panel shows the exact hostname, private IP, and a ready-to-paste command. SSH auth uses the same fleet key your workspace's ssh-agent already holds; the login user (ec2-user / ubuntu) is shown in the metadata panel.

Windows targets

By default (the Join the dev AD domain parameter), Windows VMs join the dev Managed AD (sapphire.dev) at build — the same domain as the dev Windows fleet, so Kerberos-authenticated psrp works against them exactly like the fleet. The directory is rebuilt every morning and destroyed every evening: a VM built after hours or on weekends simply skips the join (the workspace metadata panel says so) and comes up as a workgroup machine — restart the workspace while the directory is up to join. A join that failed at boot is also non-fatal: check the metadata panel and C:\ProgramData\Amazon\EC2Launch\log on the VM.

Against a joined VM, the whole fleet path just works: the dynamic inventory picks the VM up, the _Windows/os_windows group_vars supply the domain account and vaulted password over psrp, and ansible <host>.sapphire.dev -m ansible.windows.win_ping answers with no extra flags. To use Kerberos tickets explicitly (verified live 2026-08-11 — the machine renames to the inventory hostname when it fits NetBIOS's 15 chars, so the SPNs match):

kinit admin@SAPPHIRE.DEV        # realm is CASE-SENSITIVE and must be upper here
ansible <host>.sapphire.dev -m ansible.windows.win_ping \
  -e ansible_user=admin@SAPPHIRE.DEV -e ansible_psrp_auth=kerberos

Generate /etc/krb5.conf first — it cannot be static, because the directory is rebuilt every morning with new domain controllers. The playbook runs against a domain-joined target, discovers today's DCs from the target's SRV records, and writes the realm config on the controller; run it once per workspace, and again after the morning rebuild if your workspace survived it:

ansible-playbook --limit <host>.sapphire.dev playbooks/configure-ansible-for-kerberos-auth.yml \
  -e krb5_conf_path=/etc/krb5.conf -e krb5_conf_become=true

Password-based Kerberos (no kinit) trips a pyspnego case quirk — it derives a lowercase realm from the UPN — so prefer the ticket flow above (the generated config carries a lowercase-realm alias that softens this, but tickets are the reliable path).

A joined VM does not survive the nightly directory rebuild — the machine wakes up trusting a domain that no longer exists, and there is deliberately no automatic rejoin. Ephemeral VMs (the default) never notice: each start is a fresh build that joins the current directory. If you keep a long-term Windows VM (delete-on-stop disabled), build it with the domain join off and use the NTLM path below.

With the join disabled (or failed), the VM is a workgroup machine reachable over WinRM with NTLMpsrp minus Kerberos. This path always works, domain or not:

ansible <host>.sapphire.dev -m ansible.windows.win_ping \
  -e ansible_connection=psrp -e ansible_user=Administrator \
  -e ansible_password='<shared test-VM password>' \
  -e ansible_psrp_auth=ntlm -e ansible_psrp_cert_validation=ignore

The local Administrator password is the shared test-VM admin password — the same on every Windows test VM, set as a sensitive template variable at coder templates push time (ask your team; it is deliberately not the Ansible Vault password, because user_data is readable from inside the VM). WinRM listens on 5985 (HTTP/NTLM) and 5986 (HTTPS, self-signed); RDP (3389) is open from the dev network with the same credential. Rotating the password = pushing the template again with a new --variable windows_admin_password=… (existing VMs pick it up on their next start).

For an interactive desktop, port-forward RDP through your dev workspace (the exact command is in the workspace metadata panel):

coder ssh -L 13389:<vm-private-ip>:3389 <your-dev-workspace>
# then: mstsc /v:localhost:13389  (or any RDP client → localhost:13389)

No direct internet access — by design

Test VMs have no public IP, matching the managed fleet. Roles that download from the internet use the same squid-proxy extra-vars they already need against the dev fleet: -e @extra_vars/proxy.yml. If a role passes on a test VM only with direct egress, it would fail on the fleet — that's a bug worth catching here.

Lifecycle — and what "stopped" means

These are ephemeral test targets by default: stopping the workspace — including the 8-hour autostopdestroys the VM and every disk, and each start builds a pristine machine. There is nothing to clean up and no stale state to confuse a test: "reset to clean" is just stop/start (or wait for the autostop).

  • Long-term VMs: set the Delete the VM on stop parameter to false at create time for classic semantics — stop = EC2 stopped, disks and playbook-applied state persist, coder start resumes where you left off. Delete the workspace when the experiment is over. Build long-term Windows VMs with the domain join off (see above — joined machines don't survive the nightly directory rebuild).
  • Either way, delete the workspace when you're finished with the experiment entirely — a workspace costs nothing while stopped in ephemeral mode, but it still occupies a name and clutters coder list.

Changing parameters on an existing VM

Use the workspace's Settings → Parameters page in the Coder UI, or:

coder update <workspace> --always-prompt --parameter "data_disk_gb=100" ... # all parameters

(coder restart/start with --parameter silently keep the stored values — a known CLI gotcha.)

Molecule integration

Roles can drive these VMs through Molecule for the full create → converge → idempotence → verify → destroy lifecycle: the shared delegated-driver playbooks in molecule/shared/ (see molecule/README.md in the repo) provision platforms via coder create against this template, resolve the IPs, and hand Molecule the connections. Platform disks are declared in molecule.yml as the same YAML structure the template accepts — one format end to end. Iterate with molecule converge against a living VM (the create playbook reuses existing workspaces); the autostop TTL backstops aborted runs.

Azure test VMs

The azure-test-vm template is the Azure twin — same parameters, driven by the same Molecule plumbing, with the differences that matter:

  • Data disks are keyed by lun, not device_name (Azure addresses disks by LUN; inside the VM they enumerate as /dev/sd* in LUN order — discover by the disk_label tag, exactly like production — Azure production tags disks disk_label, where AWS uses ansible_key).
  • The Coder agent is fully functional (on by default, like AWS): private DNS resolves the Coder server to its origin over the VNet peering, so the UI terminal and coder ssh actually work on Azure test VMs — the proxy limitation that blocks them on AWS doesn't apply. Turn the agent off when you want the target pristine.
  • delete_on_stop defaults to false (persistent) — Azure test VMs predate the parameter; Molecule passes true for ephemeral targets.
  • Linux-only for now (RHEL 9 / Ubuntu 24.04); Windows targets are AWS-only.
  • SSH user is azureuser; OS disk minimum is 64 GB (the RHEL 9 image's own size).

Not yet covered

  • Kerberos against test VMs — Windows targets are workgroup machines with NTLM; roles that specifically depend on Kerberos delegation behavior still need a domain-joined host.
  • Windows on Azure — the AWS template covers Windows Server 2022/2025; the Azure twin is Linux-only.