Moving a Nine-Year-Old Windows Server from Xen to Nitro: t2.large → t3.large Without a Rebuild

By Sergey Nosov

July 15, 2026

Last night I migrated a production EC2 instance from t2.large to t3.large — which sounds like a five-minute stop/change-type/start until you learn that the box was a Windows Server 2016 install that had been running since the Xen era, and that a t3 (Nitro) instance won't even boot without storage and network drivers the old machine never had. This is the write-up I wish I'd found beforehand: what worked, what broke, and the IAM permission whack-a-mole AWS's own documentation walked me into.

Why bother

Three reasons, in honesty-order:

  1. Money. The instance had been covered by back-to-back 3-year Reserved Instances since 2017. The last one expired, and the box silently reverted to on-demand — about $88/month for a Windows t2.large in us-west-2. Just resizing to t3.large cuts that ~31% with no commitment; reserving the t3 for three years brings it to ~$43/month, cheaper than renewing the t2 ever would have been.
  2. Hardware. Same 2 vCPU / 8 GB, but Nitro instead of Xen: better network (the in-guest NIC reports 5 Gbps burst, up from 1), better EBS throughput, and a modern credit model.
  3. A deadline with teeth. The 2016 OS needs an in-place upgrade before its extended support ends. Doing the boot-critical driver work first, on a known-good OS, isolates one risky change from the other.

The numbers that drove it (us-west-2, Windows, License Included):

The blocker: a t3 is not a bigger t2

On Nitro instances, EBS volumes appear as NVMe devices and networking is ENA. My driver inventory (run this before you plan anything):

Get-WindowsDriver -Online | ? { $_.ProviderName -like '*Amazon*' } |
  Select ClassName, ProviderName, Version, Date, OriginalFileName

The results: AWS NVMe driver absent (a t3 boot would die with INACCESSIBLE_BOOT_DEVICE), ENA at 1.0.8 from 2016 (needs ≥ 2.2.3), AWS PV at 7.4.6 from 2017. A nine-year-old Windows image simply predates the hardware it's about to run on.

Tip: enaSupport=true on the instance attribute tells you nothing about the OS. Mine had the attribute set for years; the driver that makes it mean something was never installed.

Preparation that paid off

Pre-warm your snapshots the day before. The first-ever snapshot of an EBS volume copies every block and can take hours. I took crash-consistent snapshots of both volumes while the instance was running, the day before the window. Result: the real backup — a stopped-state AMI taken inside the window — completed in 14 minutes instead of potentially hours of downtime. (Related: aws ec2 wait image-available gives up after ~10 minutes and reports failure. If the AMI still shows pending, nothing is wrong — just run the wait again.) The pre-warm snapshots are disposable the moment the migration is confirmed good.

Capture a network baseline. When Windows switches from the Xen PV adapter to ENA, it's a brand-new NIC as far as the OS is concerned — any static IP or DNS configured inside Windows silently resets to DHCP. Save ipconfig /all and Get-NetAdapter | Format-List * to a file you keep off the box. Mine showed everything on DHCP, so I knew in advance the switch would be a non-event.

Interrogate Windows Update properly. My first check looked clean — no reboot pending. The full query told a different story:

Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired'
(New-Object -ComObject Microsoft.Update.Session).CreateUpdateSearcher().Search(
    "IsInstalled=0 and IsHidden=0").Updates | Select Title

Five updates queued, including the monthly cumulative and a servicing-stack update. A maintenance window that accidentally turns into "Working on updates, 3 of 47" on a Server 2016 box is a window you no longer control. Install them, reboot, and re-check before the window — the SSU can unlock a second round.

Inventory the blast radius — the box always does more than you think. Mine was "the web server" right up until Get-Service said it was also the mail server (hMailServer, primary MX for three domains, plus POP3/IMAP), four database engines (SQL Server, MySQL, PostgreSQL, and a Sybase SQL Anywhere 8 that predates some of my readers), and — surprise — authoritative DNS for two of the domains. Stopping "the web server" meant stopping a nameserver. What saved the window: the secondary NS and backup MX lived on a different host. Verify that before you take the primary down — query the secondary directly and compare SOA serials:

Resolve-DnsName example.com -Type SOA -Server <secondary-ip>

(And a trap inside that check: when I probed the backup MX on port 25 from home, the connection failed — and so did a control test against the primary MX that demonstrably receives mail all day. Most residential ISPs block outbound port 25. A failed SMTP connection test from your desk proves nothing; control-test against a known-working target before drawing conclusions.)

Quiesce services explicitly; don't trust shutdown to do it. During a Windows shutdown each service gets only a few seconds (WaitToKillServiceTimeout) before being killed — a database mid-checkpoint can need more. Stop-Service waits for a clean stop and shows you the result. Stop writers before their databases (the mail server writes into a DB — stop it first). And a small one: iisreset /stop does not stop the FTP service; Stop-Service ftpsvc is on you.

Know your stop semantics. aws ec2 stop-instances without --force performs the same orderly Windows shutdown as the Start menu — only --force is a power pull. Also check InstanceInitiatedShutdownBehavior is stop (not terminate!) and consider belt-and-braces guardrails: API termination protection plus an explicit IAM Deny on ec2:TerminateInstances for the operator credentials you'll be using at 10pm.

Driver prep: the automation, and why it took four attempts

AWS's documented path is either manual driver installs over RDP (PV, ENA, NVMe, then a PnP-only sysprep) or the AWSSupport-UpgradeWindowsAWSDrivers SSM automation. My box wasn't SSM-managed (the agent ran, but with no instance profile it never registered), which forces offline mode: the automation stops the instance, takes a backup AMI, builds a helper instance via CloudFormation, physically moves your root volume to the helper, injects the drivers into the offline Windows image, and moves it back. Same volume ID throughout — your rollback documentation stays true.

Offline mode has a hidden bonus: the drivers are injected with their boot-critical registry wiring, so the manual path's Sysprep_Generalize_Pnp step isn't needed at all.

Here's the part that cost me three failed attempts: the documented IAM permission list for this automation is stale. I built a least-privilege operator policy from AWS's own docs pages, and it had four gaps — three found the hard way, one caught in between:

  1. Attempt 1 died in seconds on autoscaling:DescribeAutoScalingInstances — the workflow's very first pre-check asserts your instance isn't in an Auto Scaling group. Not in the docs.
  2. Attempt 2 died inside CloudFormation on ec2:CreateLaunchTemplate — the modern template builds the helper via a launch template, and the documented permission list predates that change.
  3. Between attempts, a sweep of the live workflow document surfaced cloudformation:DescribeStackResourcesingular, a different IAM action from the DescribeStackResources (plural) I had granted. It would have killed the run right after stack creation.
  4. Attempt 3 died on the sneakiest one: iam:TagRole / iam:TagInstanceProfile. The template defines no tags at all — I had verified that. But CloudFormation stamps invisible aws:cloudformation:stack-name/stack-id/logical-id system tags on every taggable resource it creates, and that requires tag permissions. The error (UnauthorizedTaggingOperation) doesn't care that your template audit was clean.

The lesson, and the tip that actually ends the whack-a-mole: stop trusting documentation and interrogate the artifacts themselves. All of it is readable:

# the live automation document - grep the step list for every "Api": call it makes
aws ssm get-document --name AWSSupport-UpgradeWindowsAWSDrivers --query Content --output text

# the exact CloudFormation template a failed stack used - retrievable up to 90 days AFTER deletion
aws cloudformation get-template --stack-name <full-stack-ARN-with-id>

Then prove the policy before burning another maintenance window, with the IAM policy simulator:

aws iam simulate-principal-policy --policy-source-arn arn:aws:iam::<acct>:user/<operator> `
  --action-names iam:CreateRole iam:TagRole ec2:CreateLaunchTemplate cloudformation:CreateStack ... `
  --resource-arns <the-real-ARN-patterns>

One subtlety: if your policy uses Condition blocks (mine region-locked everything on aws:RequestedRegion and constrained iam:PassRole by service), pass matching --context-entries — otherwise the simulator reports false denials for statements whose conditions were never given a value.

And validate the gate itself: I ran the simulation against the still-broken policy first. It flagged the permission that had just failed for real (iam:TagRole) and its sibling that would have failed next (iam:TagInstanceProfile) — and nothing else. A verifier that reproduces the known failure and predicts the next one is a verifier you can trust. After the fixed policy simulated clean, attempt four ran end-to-end without a hitch.

One reassurance earned from watching it fail three times: every failure happened in the automation's setup phase — pre-checks and stack creation — before my root volume was ever touched, and each time it cleanly restarted the instance itself. The blast radius of a permission failure is wasted window time, not a broken server.

Small gotchas that would each have cost an hour

The automation ends by starting your instance. Whatever state it found the box in, the parent document's final step boots it — on Xen, still a t2. I'd planned to resize while it was conveniently stopped; the automation won that race. No harm: it boots fine on Xen with the new drivers (they coexist), you verify the driver store over RDP, and then do your own stop → resize → start. Budget for that extra cycle.

A resized instance keeps its CPU credit mode. "T3 defaults to unlimited" applies only to newly launched instances — the credit specification is per-instance state that rides through a resize. Mine was standard (the t2 default); left alone, the shiny new t3 would have throttled at its 30%-per-vCPU baseline exactly like the t2 used to, quietly undercutting half the point of migrating. And the fix is its own IAM action (ec2:ModifyInstanceCreditSpecificationnot covered by ModifyInstanceAttribute):

aws ec2 modify-instance-credit-specification `
  --instance-credit-specifications "InstanceId=i-...,CpuCredits=unlimited"

If you go unlimited, watch CPUSurplusCreditsCharged in CloudWatch for the first weeks — sustained surplus charges mean you need a bigger instance, not a credit tweak.

EC2Launch's installer extracts the zip itself. The automation upgrades drivers only — updating EC2Launch (password generation, console output on Nitro) is still manual, and install.ps1 expects a file named exactly EC2-Windows-Launch.zip sitting next to it. If you helpfully Expand-Archive first, it exits with "zip not found". Download both files into one directory and just run the script.

Server 2016 lets you split the windows. On 2008 R2/2012 you must resize immediately after driver prep; on 2016+ the box keeps booting happily on Xen afterwards, so driver prep and the resize can be separate maintenance windows if scheduling demands it.

Backup artifacts multiply — tag them at creation. The automation takes its own backup AMI per attempt ("it is your responsibility to… delete"), on top of your step-0 rollback AMI and the pre-warm snapshots. Four attempts in, I owned five AMIs. Pass --tag-specifications on everything you create so cleanup day is a tag filter, not an archaeology dig — and the tags double as a state-recovery mechanism if you ever have to reconstruct mid-migration progress from the AWS account alone.

The final sequence and the score

The window that actually worked: quiesce services → API stop → stopped-state rollback AMI (14 min, thanks to pre-warming) → offline driver automation (~15 min) → RDP driver-store verification (NVMe present and running, ENA 2.11) → stop → modify-instance-attribute to t3.large → start.

First Nitro boot: 2/2 status checks, no drama. All sites 200, DNS answering, mail flowing, ten services up, 5 Gbps NIC. (Had that boot gone dark instead, the diagnostic from outside is aws ec2 get-console-screenshot — it distinguishes "Windows is grinding through PnP device setup, be patient" from an INACCESSIBLE_BOOT_DEVICE stop error. And the rollback for a bad Nitro boot is trivially cheap: stop, flip the type back to t2, start — same disks, back on Xen.)

Total downtime across the evening, including three failed automation attempts: about 52 minutes. Cost, effective immediately: −31%; after the 3-year reservation, −51%.

The checklist I'd hand my past self

Nine years on Xen, one evening to leave. The hardware migration was the easy part — the real work was refusing to trust anything, including the documentation, without checking it against the machine.