Windows DHCP Server will not start serving on an EC2 instance out of the box. The
service starts, reports healthy, authorises against Active Directory — and then
silently answers nothing.
This is a short article about why that happens, why the obvious fix is more
dangerous than it looks, and a procedure for doing it without locking yourself out
of the box.
Everything here applies to any Windows Server running the DHCP Server role on EC2.
Replace the placeholder values with your own.
The symptom
Three things line up:
- The DHCP audit log (
) repeatedlyC:\Windows\System32\dhcp\DhcpSrvLog-<Day>.log
logs event 64,No static IP address bound to DHCP server -
returns nothing at allGet-DhcpServerv4Binding
- The service shows
, andRunninglists the serverGet-DhcpServerInDC
Everything looks fine. No listener is ever opened.
Why it happens
Windows DHCP Server refuses to bind to a network interface whose IPv4 address was
assigned by DHCP. It is a deliberate design decision by Microsoft — a DHCP server
whose own address could change underneath it is a bad idea.
AWS assigns every ENI address by DHCP. The guest has no idea the address is
actually fixed, so it disqualifies the interface and never binds.
There is no supported workaround. No registry key, policy or service flag
changes this. Your options are:
- Statically configure the address inside Windows, or
- Don’t run Windows DHCP Server on EC2
This article covers option 1.
The insight that makes it safe
AWS always hands the same address to the same ENI. The lease renews forever
with an identical result. The address is already stable — the guest just doesn’t
know it.
So you are not really “changing” the address. You are telling Windows what it
already has, in a form the DHCP Server role will accept.
This leads to the single most important rule:
Only ever configure an address that AWS has already assigned to the ENI.
If you invent an address, two things go wrong. The VPC will hand that address to
another instance, because it doesn’t know you’ve taken it. And the ENI will drop
your outbound traffic anyway, because AWS discards packets whose source IP is not
one of the ENI’s assignments. You get a half-working host and a very confusing
afternoon.
Why this is dangerous
Reconfiguring an EC2 instance’s primary interface from inside the guest can lock
you out completely. Three independent mechanisms:
1. You are standing on the branch you’re sawing
Removing the interface’s IPv4 address drops your RDP session instantly. If
anything goes wrong after that point, you have no session to fix it from.
2. Disabling DHCP destroys your metadata routes
AWS delivers routes to the link-local
metadata (IMDS), time sync, and others — using DHCP option 121. Turn DHCP off
on the interface and those routes go with it.
That breaks IMDS at
identity, so SSM breaks too. You lose RDP and your out-of-band management path
in the same instant, for the same reason.
If your recovery plan was “I’ll just use SSM”, the failure mode you’re recovering
from is the one that kills SSM.
3. Interface indexes are not stable
This is the one that catches people.
Windows interface indexes (
on, the primary interface was index
or configuration keyed to an index will be silently orphaned the next time the
machine restarts.
A previous attempt on that host hardcoded
came back from a reboot with no network connectivity at all. Everything written
against index 8 pointed at nothing.
Always resolve interfaces by MAC address. MACs are stable for the life of the
ENI. Indexes are not.
Good practice: add a second interface first
Before touching the primary interface, attach a second ENI and leave it alone.
This is the highest-value thing in this article. It converts a blind, high-stakes
operation into one you can watch happen.
- The second ENI has its own address, its own default route, and its own
connectivity. Nothing you do to the primary affects it. - You RDP to the second interface and run the reconfiguration from there.
Removing the primary’s address doesn’t touch your session. - Leave it on DHCP. That is not laziness, it’s the point. An interface that
is still DHCP-assigned can never become a binding candidate for the DHCP Server
role, so you cannot accidentally start serving DHCP from the wrong interface
onto your subnet.
An ENI attaches to a running instance with no stop and no reboot. In
CloudFormation it’s purely additive and does not replace the instance:
MgmtEni:
Type: AWS::EC2::NetworkInterface
Properties:
Description: Management lifeline - leave on DHCP
SubnetId: !Ref PrivateSubnet
GroupSet:
- !Ref ServerSecurityGroup
Tags:
- Key: Name
Value: "myserver-mgmt-eni"
MgmtEniAttachment:
Type: AWS::EC2::NetworkInterfaceAttachment
Properties:
InstanceId: !Ref MyInstance
NetworkInterfaceId: !Ref MgmtEni
DeviceIndex: "1"
DeleteOnTermination: false
Or from the CLI:
ENI=$(aws ec2 create-network-interface \
--subnet-id subnet-xxxxxxxx \
--groups sg-xxxxxxxx \
--description "Management lifeline" \
--query 'NetworkInterface.NetworkInterfaceId' --output text)
aws ec2 attach-network-interface \
--network-interface-id "$ENI" \
--instance-id i-xxxxxxxx \
--device-index 1
A caution about template shape
If your CloudFormation defines the instance with
implicit — CloudFormation has no property describing it, and you cannot add
secondary IPs to it from the template.
Do not “fix” this by restructuring the instance to use a
block. That replaces the instance: new ENI, new address, and every piece of
guest configuration gone. Add a separate ENI resource instead.
Run a change set and confirm
Before you start: rule out the two impostors
Both of these look exactly like the binding problem. Check them first or you
may spend a day fixing something that was never broken.
Is the server authorised in Active Directory?
Get-DhcpServerInDC
A domain-joined DHCP server that isn’t authorised starts cleanly, logs nothing
alarming, and ignores every request. Indistinguishable from a binding failure
unless you look.
Can the server actually reach its clients?
This one is architectural, and worth settling before any of the work below.
A VPC does not forward broadcast or multicast traffic, and its built-in DHCP
service cannot be disabled. A Windows DHCP server in a VPC can never answer
broadcast DISCOVER packets from clients in that VPC. Those clients will keep
getting addresses from AWS no matter what you configure.
It only works for relayed requests, which arrive as unicast to the server’s
private IP:
- On-premises clients behind a DHCP relay agent, reaching the VPC over VPN or
Direct Connect - Clients on Outposts or VMware Cloud on AWS
If your intended clients are EC2 instances in the same VPC, stop here. No amount
of binding configuration will make this work.
If they are relayed, check that your security group allows UDP 67-68 inbound
from the relay agent addresses, and that a return route exists.
The procedure
Seven steps. Don’t skip step 4.
Step 1 — Discover your values from the instance itself
Don’t copy addresses from a runbook. Ask the instance. This also confirms IMDS
works before you risk breaking it.
$token = Invoke-RestMethod -Method PUT -Uri 'http://169.254.169.254/latest/api/token' `
-Headers @{'X-aws-ec2-metadata-token-ttl-seconds' = '300'}
$h = @{'X-aws-ec2-metadata-token' = $token}
$base = 'http://169.254.169.254/latest/meta-data/network/interfaces/macs'
foreach ($mac in (Invoke-RestMethod "$base/" -Headers $h).Trim() -split "`n") {
$m = $mac.TrimEnd('/')
[pscustomobject]@{
MAC = $m.ToUpper() -replace ':','-'
IPs = (Invoke-RestMethod "$base/$m/local-ipv4s" -Headers $h) -replace "`n",','
Subnet = (Invoke-RestMethod "$base/$m/subnet-ipv4-cidr-block" -Headers $h)
}
}
The MAC is reformatted to match what
directly in the scripts below.
Derive the gateway and prefix length from the subnet CIDR. AWS always uses the
first usable address in the subnet as the router:
$cidr = '<SUBNET_CIDR>' # e.g. 10.0.16.0/20
$prefix = [int]$cidr.Split('/')[1]
$o = $cidr.Split('/')[0].Split('.')
$gw = "$($o[0]).$($o[1]).$($o[2]).$([int]$o[3] + 1)"
"gateway=$gw prefix=$prefix"
Capture your existing link-local routes. The exact set varies by region and
instance — don’t use a list from an article, use what your instance actually has:
Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '169.254.*' |
Format-Table ifIndex, DestinationPrefix, NextHop, RouteMetric
Write down the destinations. You will need to recreate them.
Finally, record your DNS servers. On a domain-joined host these must stay
pointing at your domain controllers:
Get-DnsClientServerAddress -AddressFamily IPv4 |
Format-Table InterfaceIndex, InterfaceAlias, ServerAddresses
Step 2 — Attach the second ENI
As above. Confirm the guest sees it:
Get-NetAdapter | Format-Table Name, ifIndex, MacAddress, Status
If it doesn’t appear, run
nudge.
Step 3 — Harden the second interface
Three problems appear the moment you attach an ENI to a domain-joined Windows
host. All three bit me in production.
$ErrorActionPreference = 'Stop'
$mgmt = Get-NetAdapter | Where-Object MacAddress -eq '<MGMT_MAC>'
if (-not $mgmt) { throw 'Lifeline adapter not found by MAC' }
$idx = $mgmt.ifIndex
# 1. Stop it registering itself in DNS
Set-DnsClient -InterfaceIndex $idx -RegisterThisConnectionsAddress $false
# 2. Give it the same DNS servers as the rest of the host
Set-DnsClientServerAddress -InterfaceIndex $idx -ServerAddresses ('<DNS1>','<DNS2>')
# 3. Break the interface metric tie
Set-NetIPInterface -InterfaceIndex $idx -AddressFamily IPv4 -InterfaceMetric 9000
Why each one matters:
DNS registration. Windows registers every adapter address in DNS by
default. Within minutes your hostname resolves round-robin to both addresses.
Anything that reaches this server by name — DHCP failover partners, monitoring,
management tooling — will hit the wrong address half the time. It presents as a
service that “works intermittently”, which is horrible to diagnose and easy to
blame on the wrong thing.
Do this before the interface has been up long enough to register. If it beat
you to it, force a re-registration and then delete the leftover record on the DNS
server:
ipconfig /registerdns
Start-Sleep -Seconds 45
Resolve-DnsName <HOSTNAME> -Type A -Server <DNS1> -DnsOnly
# if the extra address is still there:
Remove-DnsServerResourceRecord -ComputerName <DNS1> -ZoneName <ZONE> `
-RRType A -Name <SHORTNAME> -RecordData <MGMT_IP> -Force
Check every domain controller. They replicate independently, and a record you
deleted on one may still be live on another.
DNS servers. A new interface gets its DNS from the VPC DHCP option set, which
is usually AmazonProvidedDNS (the VPC’s base address + 2). That resolver cannot
resolve your Active Directory zone. If Windows prefers it, domain lookups start
failing.
Interface metric. Two NICs in the same subnet both come up with the same
metric and both get a default route. Egress interface selection becomes
ambiguous, and traffic may start leaving with a source address the receiving
system doesn’t expect — breaking anything that allowlists the primary IP. Give
the lifeline a high metric so the primary wins normal routing, but keep its
default route intact so it still works when the primary is broken.
Step 4 — Prove the lifeline works
RDP directly to the second interface’s IP address. Not the hostname. Not the
primary.
Do not continue until this succeeds.
An untested lifeline is worse than no lifeline, because it makes you willing to
take risks you have no way back from. This step costs a minute and is the entire
reason the rest of this is safe.
Step 5 — Make the primary interface static
Run this from the RDP session on the second interface.
The script resolves the index from MAC at runtime, scopes everything to IPv4,
waits for the old address to release, arms a rollback before making any change,
and reverts automatically if verification fails.
#requires -RunAsAdministrator
$ErrorActionPreference = 'Stop'
$PRIM_MAC = '<PRIMARY_MAC>'
$MGMT_MAC = '<MGMT_MAC>'
$IP = '<PRIMARY_IP>' # exactly what AWS assigned - do not invent one
$PREFIX = <PREFIX_LENGTH>
$GW = '<GATEWAY>'
$DNS = @('<DNS1>','<DNS2>')
$LL = @('169.254.169.253','169.254.169.254') # your captured list
# --- preflight ---
$prim = Get-NetAdapter | Where-Object MacAddress -eq $PRIM_MAC
$mgmt = Get-NetAdapter | Where-Object MacAddress -eq $MGMT_MAC
if (-not $prim) { throw 'Primary adapter not found by MAC' }
if (-not $mgmt -or $mgmt.Status -ne 'Up') { throw 'Lifeline down - ABORT' }
$idx = $prim.ifIndex
"Primary ifIndex=$idx Lifeline ifIndex=$($mgmt.ifIndex)"
# --- arm the rollback BEFORE changing anything ---
New-Item -ItemType Directory -Path C:\ProgramData\NetFix -Force | Out-Null
@"
`$a = Get-NetAdapter | Where-Object MacAddress -eq "$PRIM_MAC"
if (`$a) {
Remove-NetRoute -InterfaceIndex `$a.ifIndex -AddressFamily IPv4 -Confirm:`$false -EA SilentlyContinue
Remove-NetIPAddress -InterfaceIndex `$a.ifIndex -AddressFamily IPv4 -Confirm:`$false -EA SilentlyContinue
Set-NetIPInterface -InterfaceIndex `$a.ifIndex -AddressFamily IPv4 -Dhcp Enabled
Set-DnsClientServerAddress -InterfaceIndex `$a.ifIndex -ResetServerAddresses
Restart-NetAdapter -InterfaceAlias `$a.Name
}
"@ | Set-Content C:\ProgramData\NetFix\revert-dhcp.ps1 -Encoding UTF8
Register-ScheduledTask -TaskName 'NetRollback' -Force -User 'SYSTEM' -RunLevel Highest `
-Action (New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -ExecutionPolicy Bypass -File C:\ProgramData\NetFix\revert-dhcp.ps1') `
-Trigger (New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(15)) | Out-Null
"Rollback armed for $((Get-Date).AddMinutes(15).ToString('HH:mm:ss'))"
# --- apply ---
Set-NetIPInterface -InterfaceIndex $idx -AddressFamily IPv4 -Dhcp Disabled
Remove-NetRoute -InterfaceIndex $idx -AddressFamily IPv4 -Confirm:$false -EA SilentlyContinue
Remove-NetIPAddress -InterfaceIndex $idx -AddressFamily IPv4 -Confirm:$false -EA SilentlyContinue
$t = 0
while ((Get-NetIPAddress -InterfaceIndex $idx -AddressFamily IPv4 -EA SilentlyContinue) -and $t -lt 30) {
Start-Sleep 1; $t++
}
if ($t -ge 30) { throw 'Address did not release in 30s' }
New-NetIPAddress -InterfaceIndex $idx -IPAddress $IP -PrefixLength $PREFIX `
-DefaultGateway $GW -AddressFamily IPv4 | Out-Null
Set-DnsClientServerAddress -InterfaceIndex $idx -ServerAddresses $DNS
foreach ($r in $LL) {
New-NetRoute -InterfaceIndex $idx -DestinationPrefix "$r/32" -NextHop $GW `
-PolicyStore ActiveStore -Confirm:$false | Out-Null
}
Start-Sleep 5
# --- verify ---
$fail = @()
$a = Get-NetIPAddress -InterfaceIndex $idx -AddressFamily IPv4 -EA SilentlyContinue
if ($a.IPAddress -ne $IP) { $fail += "address='$($a.IPAddress)'" }
if ($a.AddressState -ne 'Preferred') { $fail += "state='$($a.AddressState)'" }
if ($a.PrefixOrigin -ne 'Manual') { $fail += "origin='$($a.PrefixOrigin)'" }
if (-not (Test-Connection $GW -Count 2 -Quiet)) { $fail += 'gateway unreachable' }
try { Resolve-DnsName $env:USERDNSDOMAIN -Server $DNS[0] -EA Stop | Out-Null }
catch { $fail += 'DNS failed' }
try { Invoke-RestMethod -Method PUT -Uri 'http://169.254.169.254/latest/api/token' `
-Headers @{'X-aws-ec2-metadata-token-ttl-seconds'='60'} -TimeoutSec 5 | Out-Null }
catch { $fail += 'IMDS unreachable' }
# --- commit or revert ---
if ($fail.Count -eq 0) {
Unregister-ScheduledTask -TaskName 'NetRollback' -Confirm:$false
'PASS - static applied. DO NOT REBOOT YET.'
Restart-Service DHCPServer
Start-Sleep 5
Get-DhcpServerv4Binding
} else {
Write-Warning ("FAILED: {0} - reverting" -f ($fail -join '; '))
& C:\ProgramData\NetFix\revert-dhcp.ps1
Unregister-ScheduledTask -TaskName 'NetRollback' -Confirm:$false -EA SilentlyContinue
'REVERTED to DHCP'
}
Do not add a reboot to this script. Persistence is a separate test, run
deliberately, once you’ve confirmed the change itself worked.
If it passes, you should see the interface appear:
InterfaceAlias IPAddress BindingState
-------------- --------- ------------
Ethernet 2 10.0.16.42 True
In my case
moment the address became
yours shows
Set-DhcpServerv4Binding -InterfaceAlias $prim.Name -BindingState $true
The lifeline interface should not appear in that list. If it does, someone has
statically configured it and you’ve lost your safety property.
Step 6 — Make the link-local routes actually persist
This is the step most write-ups get wrong, including my own first attempt.
reboot. Those persistent routes are keyed to the interface index. When the
index changes — and it will — the entries are discarded. I lost all twelve of
mine on the first reboot and was left with only the default route.
The default gateway survived, because
into the adapter’s TCP/IP configuration, which is keyed by adapter GUID.
GUIDs are stable.
So the real distinction is where the configuration is stored, not the form of
the route. A widely repeated claim that “gateway-form routes survive
re-enumeration while on-link ones don’t” is not the mechanism.
Stop fighting the persistent route store. Use an idempotent boot task that
resolves adapters by MAC:
New-Item -ItemType Directory -Path C:\ProgramData\NetFix -Force | Out-Null
@'
$ErrorActionPreference = "SilentlyContinue"
$GW = "<GATEWAY>"
$LL = @("169.254.169.253","169.254.169.254") # your captured list
$MAC = @("<PRIMARY_MAC>","<MGMT_MAC>")
foreach ($m in $MAC) {
$a = Get-NetAdapter | Where-Object MacAddress -eq $m
if (-not $a) { continue }
foreach ($d in $LL) {
if (-not (Get-NetRoute -InterfaceIndex $a.ifIndex -DestinationPrefix "$d/32" -EA SilentlyContinue)) {
New-NetRoute -InterfaceIndex $a.ifIndex -DestinationPrefix "$d/32" `
-NextHop $GW -PolicyStore ActiveStore -Confirm:$false
}
}
}
'@ | Set-Content C:\ProgramData\NetFix\ensure-imds-routes.ps1 -Encoding UTF8
$trig = New-ScheduledTaskTrigger -AtStartup
$trig.Delay = 'PT60S'
Register-ScheduledTask -TaskName 'EnsureImdsRoutes' -Force -User 'SYSTEM' -RunLevel Highest `
-Action (New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -ExecutionPolicy Bypass -File C:\ProgramData\NetFix\ensure-imds-routes.ps1') `
-Trigger $trig | Out-Null
& C:\ProgramData\NetFix\ensure-imds-routes.ps1
It only adds what’s missing, so it’s safe to run repeatedly, and it doesn’t care
what the indexes are today.
Note it also covers the lifeline interface. Worth knowing: a second ENI does
not automatically receive the
would and wrote that down as fact; it was wrong. Check yours rather than assuming,
and add them explicitly if they’re absent — otherwise your lifeline gives you RDP
but not IMDS or SSM.
Step 7 — Verify, then reboot as a separate test
Get-NetAdapter | Format-Table Name, ifIndex, MacAddress, Status
Get-NetIPAddress -AddressFamily IPv4 | Format-Table InterfaceIndex, IPAddress, PrefixOrigin, AddressState
Get-NetIPInterface -AddressFamily IPv4 | Format-Table InterfaceIndex, InterfaceAlias, Dhcp, InterfaceMetric
Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '169.254.169.*' |
Sort-Object ifIndex | Format-Table ifIndex, DestinationPrefix, NextHop
try { Invoke-RestMethod -Method PUT -Uri 'http://169.254.169.254/latest/api/token' `
-Headers @{'X-aws-ec2-metadata-token-ttl-seconds'='60'} -TimeoutSec 5 | Out-Null
'IMDS: OK' } catch { 'IMDS: FAILED' }
Get-Service DHCPServer, AmazonSSMAgent | Format-Table Name, Status
Get-DhcpServerv4Binding
Get-DhcpServerInDC
What you’re checking:
| Check | Expected |
|---|---|
| Primary address | Your address,
PrefixOrigin: Manual AddressState: Preferred |
| Lifeline address | Still
PrefixOrigin: Dhcp |
| Interface metrics | Primary low, lifeline high |
| Link-local routes | Present on both interfaces |
| IMDS |
OK |
| Binding | Primary listed with
BindingState: True |
| Audit log | No event
64 |
| DNS | Only the primary address resolves for the hostname, on every DC |
Then reboot — deliberately, as its own test, with the lifeline still attached.
Expect the interface indexes to change. That’s not a failure, it’s the whole
point of resolving by MAC. Re-run the block above and confirm the addressing,
routes and binding all survived.
Useful audit log event codes:
| Event | Meaning |
|---|---|
|
00 01 | Service started / stopped |
|
55 | Authorized(servicing) — the healthy steady state |
|
64 | No static IP address bound — the fault you’re fixing |
|
24 25 | Database cleanup — routine noise |
Cleaning up
Remove the temporary rollback task once you’re satisfied:
Unregister-ScheduledTask -TaskName 'NetRollback' -Confirm:$false -EA SilentlyContinue
Do not leave an automatic revert armed indefinitely. Months later an unrelated
network blip will trip it, silently disable your static address, and break DHCP
with no obvious cause.
Keep
runbook so nobody deletes it as debris. Keep
bearing.
The gap nobody closes
None of this lives in your infrastructure code.
The static addressing, the interface metrics, the DNS settings, the routes, the
boot task — all of it exists only in that instance’s registry and filesystem. Your
CloudFormation or Terraform knows about the ENI and nothing else.
If the instance is ever replaced, all of it silently disappears and you are
back to a DHCP server that won’t bind. Most likely discovered at the worst
possible moment, by someone who wasn’t involved in any of this.
Two ways to close it:
- Encode the configuration in UserData so a rebuilt instance configures itself
- Or accept it, and write a runbook that is explicitly mandatory on rebuild
Either is fine. Doing neither is the common outcome, and it’s a trap.
Summary of things to look at
If you take nothing else from this:
- Check AD authorisation and client reachability first. Both look identical to
the binding fault. - Only configure addresses AWS has assigned. Never invent one.
- Never hardcode an interface index. Resolve by MAC, every time.
- Attach a second ENI and leave it on DHCP. Test it before you rely on it.
- Capture your own link-local routes. The set varies; don’t copy a list.
- Don’t trust
to persist. Use an idempotent boot task.route -p
- Disable DNS registration on new interfaces before they register themselves.
- Scope everything to
so you don’t clobber IPv6.-AddressFamily IPv4
- Never reboot in the same run as the change. Test persistence separately.
- Get the configuration into IaC, or accept that a rebuild undoes everything.