Running Windows DHCP Server on EC2: converting an interface from DHCP to static

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 (
    C:\Windows\System32\dhcp\DhcpSrvLog-<Day>.log
    ) repeatedly
    logs event 64,
    No static IP address bound to DHCP server
  • Get-DhcpServerv4Binding
    returns nothing at all
  • The service shows
    Running
    , and
    Get-DhcpServerInDC
    lists the server

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:

  1. Statically configure the address inside Windows, or
  2. 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

169.254.169.x
services — instance
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

169.254.169.254
. The SSM agent depends on IMDS for instance
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 (

ifIndex
) change across reboots. On one host I worked
on, the primary interface was index
8
, then
17
, then
18
. Any script, route,
or configuration keyed to an index will be silently orphaned the next time the
machine restarts.

A previous attempt on that host hardcoded

$idx = 8
, worked perfectly, and then
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

SubnetId
and
SecurityGroupIds
directly on
AWS::EC2::Instance
, the primary ENI is
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

NetworkInterfaces

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

Replacement: False
before you deploy anything.


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

Get-NetAdapter
reports, so you can use it
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

pnputil /scan-devices
— hot-add sometimes needs a
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

BindingState
came back
True
on its own — Windows re-evaluated the
moment the address became
Manual
, before the service was even restarted. If
yours shows
False
, enable it explicitly:

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.

route -p add &lt;dest&gt; mask &lt;mask&gt; &lt;gateway&gt; if &lt;index&gt;
does not survive a
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

New-NetIPAddress -DefaultGateway
writes
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

169.254.169.x
routes from AWS. I assumed it
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:

CheckExpected
Primary addressYour address,
PrefixOrigin: Manual
,
AddressState: Preferred
Lifeline addressStill
PrefixOrigin: Dhcp
Interface metricsPrimary low, lifeline high
Link-local routesPresent on both interfaces
IMDS
OK
BindingPrimary listed with
BindingState: True
, lifeline absent
Audit logNo event
64
after the last service start
DNSOnly 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:

EventMeaning
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

revert-dhcp.ps1
as a documented manual recovery tool, and say so in your
runbook so nobody deletes it as debris. Keep
EnsureImdsRoutes
— it’s load
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
    route -p
    to persist.
    Use an idempotent boot task.
  • Disable DNS registration on new interfaces before they register themselves.
  • Scope everything to
    -AddressFamily IPv4
    so you don’t clobber IPv6.
  • Never reboot in the same run as the change. Test persistence separately.
  • Get the configuration into IaC, or accept that a rebuild undoes everything.

About the Author: Phil

Leave a Reply

Your email address will not be published. Required fields are marked *