IT

Build guide

Building the labFrom an empty virtual machine to a hybrid Exchange

Exchange 2019 CU15 · Windows Server 2022 · Proxmox VE 9 · pfSense 2.8
10 parts · 52 sections · 61 command blocks
Revised August 2026
/ to search

Before you start

This manual takes you from an empty hypervisor to a highly available Exchange 2019, with perimeter transport, a load balancer and synchronisation to Microsoft 365. Nine parts, in order: each one assumes the one before it.

Its companion is the lab manual, which explains why each piece is built that way. This one is the how. Where a choice has a long justification, this manual summarises it in a line and points there.

#What you need before the first command

L1

ItemRequirementNotes
HypervisorProxmox VE 9 on physical hardware28 threads, 32 GB of RAM, SSD storage
Memory38 GB at full tiltMore than physical: you work in power-on groups
ISOsWindows Server 2022, Exchange 2019 CU15, pfSense CE 2.8, Debian 13 netinst
LicencesNone: Exchange in Standard Evaluation, Windows in evaluation180 days
TenantA Microsoft 365 tenant with a verified public domainYou need a verifiable subdomain
TimeA week of evenings, not an afternoonThe hybrid is the long part

#The order of the steps, and why it is that one

L1

Each phase exists because the next one needs it. Jumping ahead breaks things that look unrelated.

  1. Network — first of all. Without segments and routing, no machine talks to another.
  2. Domain — authentication and DNS. Exchange will not install without them.
  3. Identity — organisational units and attributes must be decided before creating users, because moving them later generates deletions in the cloud.
  4. Exchange — schema, first server, second server, databases, DAG.
  5. Perimeter — needs Exchange installed and the clock aligned.
  6. Load balancer — needs both Exchange servers listening.
  7. Hybrid — needs everything else, plus a verified domain.

#The conventions, decided once

L1

Settle these before the first command, because they end up in every name.

ConventionValueWhy
Machine prefixLAB-Recognisable at a glance in the hypervisor list
IDs13001399Reserved range, collides with nothing
PoolLAB-EXCHANGEPermissions and bulk management
Internal domaincontoso.labFictional: a lab command cannot then hit production
NetBIOSCONTOSO
Object CNsame as samAccountNameNot cosmetic: half your permissions depend on it

Part 1 — The host and the machines

#The full sizing

L2

This is the table everything starts from. The values are the ones in service.

IDNameRoleBridgeAddressRAMDisk
1300LAB-DC01AD DS, DNS, DAG witnessvmbr9010.20.10.10/243 GB60 GB
1301LAB-MBX01Exchange, DAG nodevmbr9110.20.20.10/298 GB80 GB
1302LAB-MBX02Exchange, DAG nodevmbr9110.20.20.11/298 GB80 GB
1303LAB-LB01HAProxyvmbr9210.20.20.34/29 + VIP .382 GB20 GB
1304LAB-CLI01Application clientvmbr9310.20.30.10/244 GB60 GB
1305LAB-EDG01Perimeter transportvmbr9410.20.40.2/294 GB80 GB
1306LAB-EDG02Perimeter transportvmbr9410.20.40.3/294 GB80 GB
1310LAB-FW01pfSense firewallallsee Part 21 GB16 GB
1311LAB-SYNC01Entra Connectvmbr9010.20.10.40/244 GB60 GB

The 8 GB on the two Exchange servers is not generous: it is the floor below which setup complains and the service becomes unusable.

#The isolated bridges

L2

Five Linux bridges with no physical ports. That is isolation by construction: a bridge with no ports has no path to the host's interfaces.

BridgePortsSegment
vmbr0physical NICExisting network — the only contact with the outside
vmbr90noneManagement
vmbr91noneExchange
vmbr92noneLoad balancer
vmbr93noneClient
vmbr94noneDMZ

Creation, in /etc/network/interfaces on the host:

auto vmbr90
iface vmbr90 inet manual
    bridge-ports none
    bridge-stp off
    bridge-fd 0

Repeat for vmbr91vmbr94, then apply and check:

ifreload -a
ip -br a | grep vmbr9          # no IPv4 on the lab bridges
bridge link show | grep vmbr9  # no output = no port attached

#Creating a machine

L2

Every Windows machine in the lab is born like this. The variables are ID, name, memory, disk and bridge.

qm create 1300 --name LAB-DC01 --pool LAB-EXCHANGE --tags lab `
  --ostype win11 --machine q35 --bios ovmf `
  --cores 2 --sockets 1 --memory 3072 --balloon 0 `
  --net0 virtio,bridge=vmbr90,firewall=1 `
  --scsihw virtio-scsi-single `
  --scsi0 local-lvm:60,discard=on,ssd=1 `
  --efidisk0 local-lvm:1,efitype=4m,pre-enrolled-keys=1 `
  --ide2 local:iso/WindowsServer2022.iso,media=cdrom `
  --ide0 local:iso/virtio-win.iso,media=cdrom `
  --boot order='ide2;scsi0'

Three details that cost time when wrong:

  • --balloon 0 disables ballooning. It is mandatory on the Exchange servers: dynamic memory produces inconsistent behaviour under load.
  • The second CD-ROM with the VirtIO drivers is needed during Windows setup, which otherwise cannot see the disk. This is where you get stuck on the first attempt.
  • --ostype win11 together with q35 and UEFI is the combination that works with Server 2022.

#CPU and memory limits

L2

Set them straight away, before powering on: they exist to stop the lab starving whatever else runs on the host.

qm set <vmid> --cpulimit 2     # ceiling of 2 effective cores
qm set <vmid> --cpuunits 50    # half the default weight

#The time zone, before anything else

L1

Do this on every Windows machine, right after installing the OS, and make it the same as the host's.

Set-TimeZone -Id "W. Europe Standard Time"

Part 2 — The firewall

Before everything else: without routing between segments, no machine sees any other.

#Installing pfSense

L2

The machine has six network interfaces, one per segment plus the uplink. Assign them all at creation, because on FreeBSD they do not hot-attach.

qm create 1310 --name LAB-FW01 --pool LAB-EXCHANGE --tags lab `
  --ostype l26 --cores 2 --memory 1024 `
  --net0 virtio,bridge=vmbr90,firewall=0 `
  --net1 virtio,bridge=vmbr91,firewall=0 `
  --net2 virtio,bridge=vmbr92,firewall=0 `
  --net3 virtio,bridge=vmbr93,firewall=0 `
  --net4 virtio,bridge=vmbr0,firewall=0 `
  --net5 virtio,bridge=vmbr94,firewall=0 `
  --scsihw virtio-scsi-single --scsi0 local-lvm:16,discard=on `
  --ide2 local:iso/pfSense-CE-2.8.1.iso,media=cdrom `
  --boot order='ide2;scsi0'

If an interface has to be added later, the sequence is: snapshot, qm set, then shut down and start — not a warm reboot.

qm snapshot 1310 pre-change
qm set 1310 -net6 virtio,bridge=vmbr9X,firewall=1
qm shutdown 1310 && qm start 1310

#Assigning the interfaces

L2

In the console, then from the web interface: Interfaces → Assignments, one row per card.

InterfaceCardBridgeAddress
DCvtnet0vmbr9010.20.10.1/24
EXCHvtnet1vmbr9110.20.20.9/29
LBvtnet2vmbr9210.20.20.33/29
CLIENTvtnet3vmbr9310.20.30.1/24
WANvtnet4vmbr0DHCP from the existing network
DMZvtnet5vmbr9410.20.40.1/29

Each interface must be opened, Enable ticked, described and given a static address. The WAN stays on DHCP.

#The rules, and the three boxes to clear

L2

One Pass rule on each internal interface, with Protocol: Any, Source: Any, Destination: Any.

Permissive rules between segments are a choice: the lab exists to study Exchange, not to practise segmentation. Protection from outside lives on the WAN.

Then the three settings that produce mysterious-looking faults:

Block private networks and Block bogon networks, on the WAN. Clear them. Those filters make sense facing the real internet; here the "WAN" is a private LAN, and administrative traffic would be discarded before any rule. Port forwards end up configured and inert.

**DNS Hostname in System → General Setup. Leave it empty**: it serves the resolver's TLS verification. Putting an IP address there — an easy mistake, the field sits right next to the address one — breaks resolution even though routing is fine.

Filter rule association on every port forward. It must stay on Add associated filter rule. With None, pfSense does the NAT and blocks the traffic.

#Getting out to the internet

L2

System → General Setup: default gateway on the WAN, DNS 1.1.1.1 and 8.8.8.8, resolver enabled. Outbound NAT in automatic mode: pfSense generates masquerade rules for all internal networks itself.

The result is that every machine leaves behind a single address, and nothing in the lab is reachable from outside except through explicit forwards.

#Checking the routing

L1

From the domain controller, once it exists, every gateway must answer:

ping 10.20.10.1 ; ping 10.20.20.9 ; ping 10.20.20.33 ; ping 10.20.30.1 ; ping 10.20.40.1

The TTL in the replies distinguishes paths: 128 from a Windows host means a direct path, 127 means one hop — that is, through the firewall.

Part 3 — The domain

#Preparing the domain controller

L2

With Windows Server 2022 installed with Desktop Experience, before promoting:

Set-TimeZone -Id "W. Europe Standard Time"
Rename-Computer -NewName LAB-DC01 -Restart

After the restart, a static address — DNS points at itself, which is only correct after promotion but is set now:

New-NetIPAddress -InterfaceAlias 'Ethernet' -IPAddress 10.20.10.10 `
  -PrefixLength 24 -DefaultGateway 10.20.10.1
Set-DnsClientServerAddress -InterfaceAlias 'Ethernet' -ServerAddresses 127.0.0.1

#Promoting the forest

L2

Install-WindowsFeature AD-Domain-Services,DNS -IncludeManagementTools

Install-ADDSForest -DomainName "contoso.lab" -DomainNetbiosName "CONTOSO" `
  -ForestMode WinThreshold -DomainMode WinThreshold `
  -InstallDns:$true -CreateDnsDelegation:$false `
  -DatabasePath "C:\Windows\NTDS" -LogPath "C:\Windows\NTDS" `
  -SysvolPath "C:\Windows\SYSVOL" -NoRebootOnCompletion:$false -Force

A single domain controller. There is no second one, so restoring a snapshot does not bring the replication problems typical of multi-DC environments — but its unavailability stops authentication for everything.

#DNS: forwarders and static records

L2

Without forwarders the machines cannot resolve public names, and the consequence is that installers do not download packages and Entra Connect cannot reach Microsoft.

Set-DnsServerForwarder -IPAddress 1.1.1.1,8.8.8.8 -PassThru

Domain servers register themselves. The perimeter machines, being in a workgroup, do not: add them by hand, and without these records the perimeter subscription will fail.

dnscmd . /RecordAdd contoso.lab lab-edg01 A 10.20.40.2
dnscmd . /RecordAdd contoso.lab lab-edg02 A 10.20.40.3

#The Active Directory recycle bin

L1

Enable it now, because the operation is irreversible and later you forget.

Enable-ADOptionalFeature -Identity 'Recycle Bin Feature' `
  -Scope ForestOrConfigurationSet -Target 'contoso.lab' -Confirm:$false

It lets you restore an accidentally deleted object with all its attributes. Without it, the deletion propagates to the cloud and recovery becomes laborious.

Get-ADObject -Filter 'SamAccountName -eq "<user>"' -IncludeDeletedObjects |
  Restore-ADObject

#The additional UPN suffix

L2

A mandatory step for the hybrid, and it must happen before creating users.

Get-ADForest | Set-ADForest -UPNSuffixes @{add="lab.impicciando.it"}
Get-ADForest | Select-Object -ExpandProperty UPNSuffixes

contoso.lab is not routable on the internet, and Entra Connect refuses to synchronise non-routable UPNs. Cloud-bound users will carry a UPN on the public domain; samAccountName and Windows sign-in stay unchanged.

#The time hierarchy

L2

The domain controller is the root. Everything else hangs from it.

Internet (time.windows.com, pool.ntp.org)
  │
  LAB-DC01  → root, marked /reliable:yes
  │
  ├── domain members     → domhier (automatic)
  └── workgroup machines → manual pointer at the DC

On the domain controller:

w32tm /config /manualpeerlist:"time.windows.com,0x8 pool.ntp.org,0x8" /syncfromflags:manual /reliable:yes /update
Set-Service w32time -StartupType Automatic
Restart-Service w32time
w32tm /resync /rediscover
w32tm /query /status

On domain members, after joining the domain:

w32tm /config /syncfromflags:domhier /update
Restart-Service w32time
w32tm /resync
w32tm /query /source     # must answer with the DC's name

#Turning off IE Enhanced Security

L1

Needed later, for Entra Connect: enhanced protection blocks the Microsoft sign-in window and the screen stays blank, with no explanation.

$adminKey = "HKLM:\SOFTWARE\Microsoft\Active Setup\InstalledComponents\{A509B1A7-37EF-4b3f-8CFC-4F3A74704073}"
$userKey  = "HKLM:\SOFTWARE\Microsoft\Active Setup\InstalledComponents\{A509B1A8-37EF-4b3f-8CFC-4F3A74704073}"
Set-ItemProperty -Path $adminKey -Name "IsInstalled" -Value 0
Set-ItemProperty -Path $userKey  -Name "IsInstalled" -Value 0
Stop-Process -Name Explorer -Force

Part 4 — Identity

Do this part before creating any user. Moving objects after synchronisation is live generates deletions in the cloud.

#The nine organisational units

L2

The structure reflects a complete account lifecycle.

$base = "DC=contoso,DC=lab"
'OPERATIVI','SEDE','SUPPORTO','ESTERNI','CASELLE',
'DISMESSI','DISABILITATI','ELIMINAZIONE','SYNC' |
  ForEach-Object { New-ADOrganizationalUnit -Name $_ -Path $base -ProtectedFromAccidentalDeletion $true }

SYNC serves a different purpose from the rest: it is the synchronisation fence. Entra Connect will look at that one exclusively, so any object outside it does not, for the tenant, exist. As long as the scope stays narrow, a configuration mistake can affect the test objects at worst.

#The attributes that decide

L2

Two attributes drive everything else. Choose them now, because they go into the synchronisation rule and the scripts.

AttributeExchange viewValuesUse
employeeTypeInterno, Consulente, Funzione, EsternoSelects the provisioning branch and the sync rule
extensionAttribute1CustomAttribute1SYNC365, NOSYNCAdmits to synchronisation anyone not Interno
extensionAttribute2CustomAttribute2dateTermination, used by decommissioning processes

employeeType is part of the standard schema. The extensionAttribute fields arrive with the Exchange schema extension, applied in Part 5 — so populating them comes after.

#Creating users and groups

L1

The rule that matters more than any other here: the CN must match the samAccountName.

New-ADUser -Name "mario.rossi" -DisplayName "Mario Rossi" `
  -SamAccountName "mario.rossi" `
  -UserPrincipalName "mario.rossi@lab.impicciando.it" `
  -Path "OU=SYNC,DC=contoso,DC=lab" `
  -OtherAttributes @{employeeType="Interno"} `
  -AccountPassword (Read-Host -AsSecureString "Password") -Enabled $true

The readable name belongs in DisplayName, which is what shows in the address book. If the CN diverges, Add-ADPermission — which resolves by object name — fails with wasn't found while Add-MailboxPermission works: you get a script that assigns half the permissions without stopping.

Checking and fixing non-conforming objects:

Get-ADUser -SearchBase "OU=SYNC,DC=contoso,DC=lab" -Filter * -Properties Name |
  Where-Object { $_.Name -ne $_.SamAccountName } |
  ForEach-Object { Rename-ADObject -Identity $_.DistinguishedName -NewName $_.SamAccountName }

Groups must be Universal Security groups, not distribution: they are mail-enabled security groups, and the scripts check the type explicitly.

New-ADGroup -Name "Gufficio.acquisti" -SamAccountName "Gufficio.acquisti" `
  -GroupCategory Security -GroupScope Universal `
  -Path "OU=SYNC,DC=contoso,DC=lab"

Part 5 — Exchange

#The prerequisites

L3

On both future mailbox servers, after joining the domain:

Install-WindowsFeature Server-Media-Foundation, NET-Framework-45-Features, `
  RPC-over-HTTP-proxy, RSAT-Clustering, RSAT-Clustering-CmdInterface, `
  RSAT-Clustering-Mgmt, RSAT-Clustering-PowerShell, WAS-Process-Model, `
  Web-Asp-Net45, Web-Basic-Auth, Web-Client-Auth, Web-Digest-Auth, `
  Web-Dir-Browsing, Web-Dyn-Compression, Web-Http-Errors, Web-Http-Logging, `
  Web-Http-Redirect, Web-Http-Tracing, Web-ISAPI-Ext, Web-ISAPI-Filter, `
  Web-Lgcy-Mgmt-Console, Web-Metabase, Web-Mgmt-Console, Web-Mgmt-Service, `
  Web-Net-Ext45, Web-Request-Monitor, Web-Server, Web-Stat-Compression, `
  Web-Static-Content, Web-Windows-Auth, Web-WMI, Windows-Identity-Foundation `
  -Restart

Plus the Visual C++ 2012 and 2013 x64 redistributables and the Unified Communications Managed API. .NET Framework 4.8 is already present in Server 2022.

#Extending the schema

L3

Run once only, from the installation media, with an account that is both Schema Admin and Enterprise Admin.

.\Setup.exe /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF /PrepareSchema
.\Setup.exe /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF /PrepareAD /OrganizationName:"CONTOSO"
.\Setup.exe /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF /PrepareAllDomains

Checking that the extension took:

Get-ADObject "CN=ms-Exch-Schema-Version-Pt,CN=Schema,CN=Configuration,DC=contoso,DC=lab" -Properties rangeUpper |
  Select-Object rangeUpper

The organisation name goes into the administrative group and cannot be changed afterwards.

#Installing the two mailbox servers

L3

.\Setup.exe /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF `
  /Mode:Install /Role:Mailbox /InstallWindowsComponents

Then the same on the second server.

#The five-database limit

L2

The Standard edition — and evaluation behaves as Standard — allows at most five mailbox databases per server. The limit counts databases present, copies included, not active ones.

Going beyond produces:

RcrExceedDbLimitException: ... maximum databases limit of 5

The architecture that fits and gives a balanced DAG is four databases with cross copies: four objects per server.

DatabaseActive onCopy onPreference
DB01LAB-MBX01LAB-MBX02MBX01 = 1
DB02LAB-MBX02LAB-MBX01MBX02 = 1
DB03LAB-MBX01LAB-MBX02MBX01 = 1
DB04LAB-MBX02LAB-MBX01MBX02 = 1

#Creating the databases

L2

New-MailboxDatabase -Name DB01 -Server LAB-MBX01 `
  -EdbFilePath "C:\ExchDB\DB01\DB01.edb" -LogFolderPath "C:\ExchDB\DB01\Logs"
New-MailboxDatabase -Name DB03 -Server LAB-MBX01 `
  -EdbFilePath "C:\ExchDB\DB03\DB03.edb" -LogFolderPath "C:\ExchDB\DB03\Logs"
New-MailboxDatabase -Name DB02 -Server LAB-MBX02 `
  -EdbFilePath "C:\ExchDB\DB02\DB02.edb" -LogFolderPath "C:\ExchDB\DB02\Logs"
New-MailboxDatabase -Name DB04 -Server LAB-MBX02 `
  -EdbFilePath "C:\ExchDB\DB04\DB04.edb" -LogFolderPath "C:\ExchDB\DB04\Logs"

Get-MailboxDatabase | ForEach-Object { Mount-Database $_.Name }

The information store needs a restart after creation, otherwise the new databases will not mount:

Restart-Service MSExchangeIS

#The DAG

L3

The witness first, on the domain controller. The folder and share are created by hand, and the Exchange servers group needs full control.

New-Item -ItemType Directory C:\DAG1_FSW
New-SmbShare -Name DAG1.contoso.lab -Path C:\DAG1_FSW `
  -FullAccess "CONTOSO\Exchange Trusted Subsystem"
Add-LocalGroupMember -Group Administrators -Member "CONTOSO\Exchange Trusted Subsystem"

Then the group, with no IP address: the model recommended from Exchange 2013 onward.

New-DatabaseAvailabilityGroup -Name DAG1 `
  -WitnessServer LAB-DC01.contoso.lab -WitnessDirectory C:\DAG1_FSW `
  -DatabaseAvailabilityGroupIPAddresses ([System.Net.IPAddress]::None)

Add-DatabaseAvailabilityGroupServer -Identity DAG1 -MailboxServer LAB-MBX01
Add-DatabaseAvailabilityGroupServer -Identity DAG1 -MailboxServer LAB-MBX02

#The copies

L2

Each database gets a copy on the other node, with activation preference 2.

Add-MailboxDatabaseCopy -Identity DB01 -MailboxServer LAB-MBX02 -ActivationPreference 2
Add-MailboxDatabaseCopy -Identity DB03 -MailboxServer LAB-MBX02 -ActivationPreference 2
Add-MailboxDatabaseCopy -Identity DB02 -MailboxServer LAB-MBX01 -ActivationPreference 2
Add-MailboxDatabaseCopy -Identity DB04 -MailboxServer LAB-MBX01 -ActivationPreference 2

Seeding starts by itself. Check:

Get-MailboxDatabaseCopyStatus * |
  ft Name,Status,ActiveCopy,CopyQueueLength,ReplayQueueLength,ContentIndexState -Auto

Mounted is the active copy, Healthy an aligned passive one. Both queues at zero means replication is current.

Test-ReplicationHealth -Identity LAB-MBX01 | Where-Object Result -ne 'Passed'

#Accepted domains

L2

DomainTypePurpose
contoso.labAuthoritativeInternal domain, default
lab.impicciando.itAuthoritativeDomain verified in the tenant — primary addresses
<tenant>.mail.onmicrosoft.comInternalRelayRouting to Exchange Online
New-AcceptedDomain -Name "lab.impicciando.it" -DomainName "lab.impicciando.it" -DomainType Authoritative
New-AcceptedDomain -Name "routing" -DomainName "<tenant>.mail.onmicrosoft.com" -DomainType InternalRelay

The routing domain is what makes Enable-RemoteMailbox possible: without it, Exchange rejects the target address. InternalRelay is the correct type for a domain shared between on-premises and cloud.

#Disabling the address policy

L2

On every object the scripts manage.

Set-Mailbox <user> -EmailAddressPolicyEnabled $false `
  -PrimarySmtpAddress <user>@lab.impicciando.it

While the policy is active it decides the primary address, and every attempt to set it by hand fails. Worse: addresses get recalculated on contoso.lab, which cannot be verified in the tenant, so Entra discards them and assigns a service address instead. The full reasoning: Who decides the primary address.

#The application connectors

L2

Two connectors reproduce the pair typical of production, and the difference explains two distinct errors.

AnonymousAuthenticated
Port25587
PermissionsAnonymousUsers + explicit relayExchangeUsers
ControlSource address onlyAddress and credentials
Typical error550 if the address is not listed530 5.7.57 if it does not authenticate
New-ReceiveConnector -Name "smtp-app-lab" -TransportRole FrontendTransport -Server LAB-MBX01 `
  -Bindings 0.0.0.0:25 `
  -RemoteIPRanges 10.20.20.10,10.20.20.11,10.20.20.34,10.20.30.10,fe80::/64 `
  -PermissionGroups AnonymousUsers -AuthMechanism Tls -Enabled $true

Get-ReceiveConnector "LAB-MBX01\smtp-app-lab" |
  Add-ADPermission -User "NT AUTHORITY\ANONYMOUS LOGON" `
    -ExtendedRights "Ms-Exch-SMTP-Accept-Any-Recipient"

The second command is the one people forget: without the extended right, an outbound send gets 550 5.7.54 even with everything else correct.

fe80::/64 is in the ranges from the start, and that is not pedantry: a connector with IPv4 ranges only is never selected for an IPv6 connection, which lands on Default Frontend and is rightly refused. The story: 550 5.7.54 on the right connector.

Finally the protocol logs, which every later diagnosis will need:

Get-ReceiveConnector -Server LAB-MBX01 | Set-ReceiveConnector -ProtocolLoggingLevel Verbose

Part 6 — The perimeter

Two machines outside the domain, in a DMZ. They never contact Active Directory: they keep a copy of the configuration in AD LDS, populated by EdgeSync.

#Preparing the workgroup machines

L3

After installing the OS, without joining the domain:

Set-TimeZone -Id "W. Europe Standard Time"
Rename-Computer -NewName LAB-EDG01 -Restart

Now address and DNS — which points at the domain controller even though the machine is not in the domain:

New-NetIPAddress -InterfaceAlias 'Ethernet' -IPAddress 10.20.40.2 `
  -PrefixLength 29 -DefaultGateway 10.20.40.1
Set-DnsClientServerAddress -InterfaceAlias 'Ethernet' -ServerAddresses 10.20.10.10
Set-NetConnectionProfile -NetworkCategory Private

Time, with a manual pointer because there is no domain hierarchy to follow:

w32tm /config /manualpeerlist:"10.20.10.10,0x8" /syncfromflags:manual /update
Set-Service w32time -StartupType Automatic
Restart-Service w32time
w32tm /resync

#The primary DNS suffix

L3

The step that, when skipped, makes the subscription fail with an error that never mentions it.

$k = 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters'
Set-ItemProperty $k -Name 'Domain'    -Value 'contoso.lab'
Set-ItemProperty $k -Name 'NV Domain' -Value 'contoso.lab'
Restart-Computer

A workgroup machine does not inherit the suffix from the domain. Without these two keys the full name stays the bare NetBIOS name. Check:

[System.Net.Dns]::GetHostEntry($env:COMPUTERNAME).HostName

It must answer lab-edg01.contoso.lab. If it answers LAB-EDG01, the subscription will fail.

#Installing the role

L3

Install-WindowsFeature ADLDS

Plus the Visual C++ 2012 and 2013 x64 redistributables. Then:

.\Setup.exe /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF /Mode:Install /Role:EdgeTransport

#The EdgeSync subscription

L3

On the perimeter machine — the file contains credentials and is valid for 1440 minutes:

New-EdgeSubscription -FileName "C:\EdgeSubscription-LAB-EDG01.xml"

Transferring the file — the machine is in a workgroup, so it needs explicit credentials:

net use Z: \\lab-mbx01.contoso.lab\C$ /user:CONTOSO\Administrator
Copy-Item C:\EdgeSubscription-LAB-EDG01.xml Z:\
net use Z: /delete

On the mailbox server:

Get-ExchangeServer LAB-MBX01 | fl Name,Site      # for the site name

New-EdgeSubscription -FileData ([byte[]]$(Get-Content -Path "C:\EdgeSubscription-LAB-EDG01.xml" -Encoding Byte -ReadCount 0)) -Site "Default-First-Site-Name"

Start-EdgeSynchronization
Test-EdgeSynchronization

#When it does not start: the three points

L2

StateMeaning
SyncStatus : Normal, CookieRecords > 0Working
SyncStatus : Inconclusive, all NotStartedExpected right after creation: the first cycle has not run
CouldNotConnect, The LDAP server is unavailableTime, or services needing a restart
Skipped statesNormal: nothing new to synchronise

The DNS suffix, if the full name is not the right one.

The clock. EdgeSync uses self-signed certificates evaluated against the current time: a few minutes of skew makes them invalid, and the error talks about connectivity, not time.

The services. Even with the right time and an open port, EdgeSync may not start until the services that evaluate certificates are restarted:

Restart-Service ADAM_MSExchange -Force
Restart-Service MSExchangeEdgeCredential
Restart-Service MSExchangeTransport

Checking the port, from the mailbox server:

Test-NetConnection 10.20.40.2 -Port 50636 -InformationLevel Quiet

#Proving the path

L1

A message to a non-existent external domain must sit in the queue in Retry with NextHopDomain populated: that is the expected result, and it proves the internal → perimeter path works.

Get-Queue | ft Identity,DeliveryType,Status,MessageCount,NextHopDomain -Auto

With the procedure repeated on the second machine, the outbound connector lists both and failover works with no intervention: shut the first down and mail keeps leaving through the second.

Part 7 — The load balancer

It reproduces the behaviour of a commercial balancer: it receives on a virtual address and forwards to the Exchange servers in SNAT, so the source address Exchange sees belongs to a different /29 from the nodes.

#Minimal Debian and the VIP

L3

A netinst installation with no desktop. The VIP is an alias on the main interface, in /etc/network/interfaces:

auto ens18:0
iface ens18:0 inet static
    address 10.20.20.38
    netmask 255.255.255.248
ifup ens18:0
ip -br a        # ens18 must show both .34 and .38

#HAProxy

L2

apt install haproxy

Configuration in /etc/haproxy/haproxy.cfg — twenty-nine lines, of which two actually matter:

global
    log /dev/log local0
    maxconn 4096
    daemon

defaults
    log     global
    mode    tcp
    option  tcplog
    timeout connect 10s
    timeout client  5m
    timeout server  5m

frontend ft_smtp
    bind 10.20.20.38:25
    default_backend bk_smtp

backend bk_smtp
    balance roundrobin
    source 10.20.20.34
    server mbx01 10.20.20.10:25 check
    server mbx02 10.20.20.11:25 check

frontend ft_https
    bind 10.20.20.38:443
    default_backend bk_https

backend bk_https
    balance roundrobin
    source 10.20.20.34
    server mbx01 10.20.20.10:443 check
    server mbx02 10.20.20.11:443 check

mode tcp — layer 4, no TLS termination: the handshake crosses the balancer intact and reaches Exchange. In HTTP mode the balancer would terminate the connection, and the TLS phenomena under study would no longer be observable.

source 10.20.20.34 — forces the source address towards the servers. That is the SNAT.

#Checking, and proving the SNAT

L1

No output from the first command means the configuration is valid:

haproxy -c -f /etc/haproxy/haproxy.cfg
systemctl restart haproxy
ss -lntp | grep -E ':25|:443'
telnet 10.20.20.38 25

The last must answer with one of the two Exchange banners. Then, in the Exchange protocol log, the line that proves the lab is faithful:

2026-08-19T00:23:03.808Z,LAB-MBX02\Default Frontend LAB-MBX02,...,
10.20.20.11:25,10.20.20.34:37058,+,,

The recorded remote address is the balancer's, not the client's.

Part 8 — The hybrid

#The tenant and the subdomain

L2

You need a Microsoft 365 tenant with an already verified public domain. The lab uses a subdomain of it.

The subdomain is verified automatically, with no TXT record: Microsoft inherits proof of ownership from the parent domain already verified in the same tenant. When adding it, deliberately skip the services step — no MX, CNAME or SPF record — because the lab only needs the domain to be verified, not to route mail.

#Installing Entra Connect

L3

On the dedicated machine, in the domain, with IE Enhanced Security already turned off.

In the wizard, in custom mode:

StepChoice
Sign-in methodPassword Hash Synchronization
Single sign-onDisabled
Forestcontoso.lab, with an administrative account
FilterOnly OU=SYNC
AnchormS-DS-ConsistencyGuid
Optional featuresPassword Hash Sync, Exchange hybrid deployment
StartLeave Start the synchronization process ticked
Import-Module ADSync
Get-ADSyncScheduler | fl SyncCycleEnabled,StagingModeEnabled,NextSyncCycleStartTimeInUTC
Set-ADSyncScheduler -SyncCycleEnabled $true

The OU filter is the tenant's main protection, and it must be re-checked on every run: the wizard presents the current settings but does nothing to stop you changing them by accident.

#Exchange hybrid deployment

L2

If it was not enabled during installation: Azure AD Connect → Customize synchronization options → Optional features.

Without it, Exchange Online does not recognise on-premises objects as valid recipients, and every delegation assignment fails with not found in EXO. With it, the same objects appear as MailUser.

It also enables writeback of certain attributes from the cloud into Active Directory: archive state, sender lists, public delegates and addresses created in the cloud. The effect is visible — on a synchronised object an X500 address appears with the prefix /o=ExchangeLabs, born in the cloud and returned. Do not remove it.

After enabling, a full cycle is mandatory:

Start-ADSyncSyncCycle -PolicyType Initial

#The custom synchronisation rule

L3

It synchronises only those with employeeType = Interno, or those carrying extensionAttribute1 = SYNC365. It writes cloudFiltered, the attribute Entra Connect uses to decide whether an object exists for the tenant.

Opening the editor:

& "C:\Program Files\Microsoft Azure AD Sync\UIShell\SyncRulesEditor.exe"
ItemValue
DirectionInbound
Connected systemcontoso.lab
Object typeuserperson
Link typeJoin
Precedence50
Target attributecloudFiltered
Flow typeExpression
IIF(IsPresent([employeeType]),
    IIF([employeeType]="Interno", False,
        IIF(IsPresent([extensionAttribute1]),
            IIF([extensionAttribute1]="SYNC365", False, True), True)),
    IIF(IsPresent([extensionAttribute1]),
        IIF([extensionAttribute1]="SYNC365", False, True), True))
employeeTypeextensionAttribute1cloudFilteredOutcome
InternoanythingFalsesynchronises
otherSYNC365Falsesynchronises
otherother or absentTrueexcluded
absentSYNC365Falsesynchronises
absentabsentTrueexcluded

Four things to know before writing it. The expression goes in the Source field: there is no separate field, and when you set FlowType = Expression the Source column becomes a free text box — this is where people get stuck looking for a field that does not exist. Precedence must be below 100, because from 100 upwards sit Microsoft's rules and an overridden rule never writes cloudFiltered. No boolean operators: the language is limited, and IsPresent must be checked before comparing an attribute that might be missing. And after every change you need an Initial cycle, because a Delta only evaluates recently changed objects.

#Changing rules safely

L2

This applies from here on, permanently.

  1. Enable staging mode
  2. Change or create the rule in the editor
  3. Run an Initial cycle
  4. Inspect the pending operations (Pending Export)
  5. Correct until no unwanted deletions appear
  6. Only then disable staging
  7. Run another Initial cycle
  8. Verify the objects in the tenant

Step 5 is the one that actually protects you. In staging the engine imports, applies the rules and computes every difference but exports nothing: you can be wrong as many times as you need.

#When a user does not synchronise

L2

The tool that answers in thirty seconds:

& "C:\Program Files\Microsoft Azure AD Sync\UIShell\miisclient.exe"

Connectors → the forest connector → Search Connector SpaceScope: DN → the object's distinguished name → PreviewGenerate PreviewImport Attribute Flow. There you see every rule applied, in precedence order, and the final value of every attribute.

Part 9 — Final checks and daily use

#The power-on order

L1

Order matters: each machine depends on the ones before. And since memory is not enough for all of them, you power on only what you need.

qm start 1310   # firewall — the network must exist before anything else
sleep 60
qm start 1300   # domain controller — authentication and DNS
sleep 90
qm start 1301 ; qm start 1302    # the two Exchange servers
sleep 120
qm start 1311   # synchronisation  (optional)
qm start 1305   # perimeter        (optional)
qm start 1303   # load balancer    (optional)
qm start 1304   # client           (optional)

Shut down in reverse order, checking first that the queues are empty.

#The checklist

L1

After every power-on, and after every maintenance:

Get-ClusterNode | ft Name,State -Auto
Get-ClusterResource | ft Name,State -Auto          # the witness must be Online
Get-MailboxDatabaseCopyStatus * | ft Name,Status,ActiveCopy -Auto
Test-ReplicationHealth -Identity LAB-MBX01 | Where-Object Result -ne 'Passed'
Get-Queue -Server LAB-MBX01 | ft Identity,Status,MessageCount -Auto
WhatExpected
Cluster nodesBoth Up
WitnessOnline
Database copiesTwo Mounted per node, the rest Healthy
Replication healthNo output
QueuesEmpty, except Shadow

Remedy:

Start-ClusterGroup "Cluster Group"

#Taking a user all the way to the cloud

L1

The proof that everything works together.

# 1 — on the domain controller: create the account in the synchronised OU
New-ADUser -Name "mario.rossi" -DisplayName "Mario Rossi" `
  -SamAccountName "mario.rossi" -UserPrincipalName "mario.rossi@lab.impicciando.it" `
  -Path "OU=SYNC,DC=contoso,DC=lab" -OtherAttributes @{employeeType="Interno"} `
  -AccountPassword (Read-Host -AsSecureString "Password") -Enabled $true

# 2 — on the mailbox server: the mailbox
Enable-Mailbox -Identity mario.rossi -Database DB01
Set-Mailbox mario.rossi -EmailAddressPolicyEnabled $false `
  -PrimarySmtpAddress mario.rossi@lab.impicciando.it

# 3 — on the synchronisation server
Start-ADSyncSyncCycle -PolicyType Delta

# 4 — verify in the tenant
Connect-MgGraph -Scopes User.Read.All -NoWelcome
Get-MgUser -All -Property UserPrincipalName,OnPremisesSyncEnabled |
  Where-Object { $_.UserPrincipalName -like "*@lab.impicciando.it" } |
  ft UserPrincipalName,OnPremisesSyncEnabled -AutoSize

If the user does not appear, there are two causes and you check them in this order: it is not in OU=SYNC, or it does not pass the rule's filter.

#When something does not add up

L1

Three principles, in order of usefulness.

Read the log, not the message on screen. The dialog's message is almost always generic; the real cause is in the event log.

Compare two independent measurements. A single figure cannot tell you whether it is right. Two figures that should agree and do not will pinpoint the problem.

Identify which layer is failing. TCP connecting but LDAP not answering means the problem is above the transport, not in the network.

The four faults that required real diagnosis during the build are told at length: the clock, the IPv6 connector, the script assumptions and the primary address. In all four the error message pointed in the wrong direction.

For the reasoning behind each design choice: the lab manual.

No results. Try a component (DAG, pfSense), a cmdlet (New-MailboxDatabase) or a phase (schema, subscription, staging).

Build sequence for a hybrid Exchange 2019 lab that is actually running. Names, addresses and internal domains have been replaced with generic values. Lab built with the support of Ilie.