Build guide
Building the labFrom an empty virtual machine to a hybrid Exchange
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
| Item | Requirement | Notes |
|---|---|---|
| Hypervisor | Proxmox VE 9 on physical hardware | 28 threads, 32 GB of RAM, SSD storage |
| Memory | 38 GB at full tilt | More than physical: you work in power-on groups |
| ISOs | Windows Server 2022, Exchange 2019 CU15, pfSense CE 2.8, Debian 13 netinst | |
| Licences | None: Exchange in Standard Evaluation, Windows in evaluation | 180 days |
| Tenant | A Microsoft 365 tenant with a verified public domain | You need a verifiable subdomain |
| Time | A week of evenings, not an afternoon | The hybrid is the long part |
#The order of the steps, and why it is that one
Each phase exists because the next one needs it. Jumping ahead breaks things that look unrelated.
- Network — first of all. Without segments and routing, no machine talks to another.
- Domain — authentication and DNS. Exchange will not install without them.
- Identity — organisational units and attributes must be decided before creating users, because moving them later generates deletions in the cloud.
- Exchange — schema, first server, second server, databases, DAG.
- Perimeter — needs Exchange installed and the clock aligned.
- Load balancer — needs both Exchange servers listening.
- Hybrid — needs everything else, plus a verified domain.
#The conventions, decided once
Settle these before the first command, because they end up in every name.
| Convention | Value | Why |
|---|---|---|
| Machine prefix | LAB- | Recognisable at a glance in the hypervisor list |
| IDs | 1300–1399 | Reserved range, collides with nothing |
| Pool | LAB-EXCHANGE | Permissions and bulk management |
| Internal domain | contoso.lab | Fictional: a lab command cannot then hit production |
| NetBIOS | CONTOSO | |
Object CN | same as samAccountName | Not cosmetic: half your permissions depend on it |
Part 1 — The host and the machines
#The full sizing
This is the table everything starts from. The values are the ones in service.
| ID | Name | Role | Bridge | Address | RAM | Disk |
|---|---|---|---|---|---|---|
| 1300 | LAB-DC01 | AD DS, DNS, DAG witness | vmbr90 | 10.20.10.10/24 | 3 GB | 60 GB |
| 1301 | LAB-MBX01 | Exchange, DAG node | vmbr91 | 10.20.20.10/29 | 8 GB | 80 GB |
| 1302 | LAB-MBX02 | Exchange, DAG node | vmbr91 | 10.20.20.11/29 | 8 GB | 80 GB |
| 1303 | LAB-LB01 | HAProxy | vmbr92 | 10.20.20.34/29 + VIP .38 | 2 GB | 20 GB |
| 1304 | LAB-CLI01 | Application client | vmbr93 | 10.20.30.10/24 | 4 GB | 60 GB |
| 1305 | LAB-EDG01 | Perimeter transport | vmbr94 | 10.20.40.2/29 | 4 GB | 80 GB |
| 1306 | LAB-EDG02 | Perimeter transport | vmbr94 | 10.20.40.3/29 | 4 GB | 80 GB |
| 1310 | LAB-FW01 | pfSense firewall | all | see Part 2 | 1 GB | 16 GB |
| 1311 | LAB-SYNC01 | Entra Connect | vmbr90 | 10.20.10.40/24 | 4 GB | 60 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
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.
| Bridge | Ports | Segment |
|---|---|---|
vmbr0 | physical NIC | Existing network — the only contact with the outside |
vmbr90 | none | Management |
vmbr91 | none | Exchange |
vmbr92 | none | Load balancer |
vmbr93 | none | Client |
vmbr94 | none | DMZ |
Creation, in /etc/network/interfaces on the host:
auto vmbr90
iface vmbr90 inet manual
bridge-ports none
bridge-stp off
bridge-fd 0Repeat for vmbr91…vmbr94, 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
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 0disables 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 win11together withq35and UEFI is the combination that works with Server 2022.
#CPU and memory limits
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
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
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
In the console, then from the web interface: Interfaces → Assignments, one row per card.
| Interface | Card | Bridge | Address |
|---|---|---|---|
DC | vtnet0 | vmbr90 | 10.20.10.1/24 |
EXCH | vtnet1 | vmbr91 | 10.20.20.9/29 |
LB | vtnet2 | vmbr92 | 10.20.20.33/29 |
CLIENT | vtnet3 | vmbr93 | 10.20.30.1/24 |
WAN | vtnet4 | vmbr0 | DHCP from the existing network |
DMZ | vtnet5 | vmbr94 | 10.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
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
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
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.1The 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
With Windows Server 2022 installed with Desktop Experience, before promoting:
Set-TimeZone -Id "W. Europe Standard Time"
Rename-Computer -NewName LAB-DC01 -RestartAfter 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
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 -ForceA 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
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 -PassThruDomain 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
Enable it now, because the operation is irreversible and later you forget.
Enable-ADOptionalFeature -Identity 'Recycle Bin Feature' `
-Scope ForestOrConfigurationSet -Target 'contoso.lab' -Confirm:$falseIt 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
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 UPNSuffixescontoso.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
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 DCOn 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 /statusOn 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
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 -ForcePart 4 — Identity
Do this part before creating any user. Moving objects after synchronisation is live generates deletions in the cloud.
#The nine organisational units
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
Two attributes drive everything else. Choose them now, because they go into the synchronisation rule and the scripts.
| Attribute | Exchange view | Values | Use |
|---|---|---|---|
employeeType | — | Interno, Consulente, Funzione, Esterno | Selects the provisioning branch and the sync rule |
extensionAttribute1 | CustomAttribute1 | SYNC365, NOSYNC | Admits to synchronisation anyone not Interno |
extensionAttribute2 | CustomAttribute2 | date | Termination, 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
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 "[email protected]" `
-Path "OU=SYNC,DC=contoso,DC=lab" `
-OtherAttributes @{employeeType="Interno"} `
-AccountPassword (Read-Host -AsSecureString "Password") -Enabled $trueThe 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
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 `
-RestartPlus 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
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 /PrepareAllDomainsChecking that the extension took:
Get-ADObject "CN=ms-Exch-Schema-Version-Pt,CN=Schema,CN=Configuration,DC=contoso,DC=lab" -Properties rangeUpper |
Select-Object rangeUpperThe organisation name goes into the administrative group and cannot be changed afterwards.
#Installing the two mailbox servers
.\Setup.exe /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF `
/Mode:Install /Role:Mailbox /InstallWindowsComponentsThen the same on the second server.
#The five-database limit
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 5The architecture that fits and gives a balanced DAG is four databases with cross copies: four objects per server.
| Database | Active on | Copy on | Preference |
|---|---|---|---|
DB01 | LAB-MBX01 | LAB-MBX02 | MBX01 = 1 |
DB02 | LAB-MBX02 | LAB-MBX01 | MBX02 = 1 |
DB03 | LAB-MBX01 | LAB-MBX02 | MBX01 = 1 |
DB04 | LAB-MBX02 | LAB-MBX01 | MBX02 = 1 |
#Creating the databases
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
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
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 2Seeding starts by itself. Check:
Get-MailboxDatabaseCopyStatus * |
ft Name,Status,ActiveCopy,CopyQueueLength,ReplayQueueLength,ContentIndexState -AutoMounted 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
| Domain | Type | Purpose |
|---|---|---|
contoso.lab | Authoritative | Internal domain, default |
lab.impicciando.it | Authoritative | Domain verified in the tenant — primary addresses |
<tenant>.mail.onmicrosoft.com | InternalRelay | Routing 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 InternalRelayThe 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
On every object the scripts manage.
Set-Mailbox <user> -EmailAddressPolicyEnabled $false `
-PrimarySmtpAddress <user>@lab.impicciando.itWhile 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
Two connectors reproduce the pair typical of production, and the difference explains two distinct errors.
| Anonymous | Authenticated | |
|---|---|---|
| Port | 25 | 587 |
| Permissions | AnonymousUsers + explicit relay | ExchangeUsers |
| Control | Source address only | Address and credentials |
| Typical error | 550 if the address is not listed | 530 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 VerbosePart 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
After installing the OS, without joining the domain:
Set-TimeZone -Id "W. Europe Standard Time"
Rename-Computer -NewName LAB-EDG01 -RestartNow 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 PrivateTime, 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
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-ComputerA 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).HostNameIt must answer lab-edg01.contoso.lab. If it answers LAB-EDG01, the subscription will fail.
#Installing the role
Install-WindowsFeature ADLDSPlus the Visual C++ 2012 and 2013 x64 redistributables. Then:
.\Setup.exe /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF /Mode:Install /Role:EdgeTransport#The EdgeSync subscription
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: /deleteOn 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
| State | Meaning |
|---|---|
SyncStatus : Normal, CookieRecords > 0 | Working |
SyncStatus : Inconclusive, all NotStarted | Expected right after creation: the first cycle has not run |
CouldNotConnect, The LDAP server is unavailable | Time, or services needing a restart |
Skipped states | Normal: 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 MSExchangeTransportChecking the port, from the mailbox server:
Test-NetConnection 10.20.40.2 -Port 50636 -InformationLevel Quiet#Proving the path
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 -AutoWith 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
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.248ifup ens18:0
ip -br a # ens18 must show both .34 and .38#HAProxy
apt install haproxyConfiguration 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 checkmode 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
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 25The 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
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
On the dedicated machine, in the domain, with IE Enhanced Security already turned off.
In the wizard, in custom mode:
| Step | Choice |
|---|---|
| Sign-in method | Password Hash Synchronization |
| Single sign-on | Disabled |
| Forest | contoso.lab, with an administrative account |
| Filter | Only OU=SYNC |
| Anchor | mS-DS-ConsistencyGuid |
| Optional features | Password Hash Sync, Exchange hybrid deployment |
| Start | Leave Start the synchronization process ticked |
Import-Module ADSync
Get-ADSyncScheduler | fl SyncCycleEnabled,StagingModeEnabled,NextSyncCycleStartTimeInUTC
Set-ADSyncScheduler -SyncCycleEnabled $trueThe 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
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
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"| Item | Value |
|---|---|
| Direction | Inbound |
| Connected system | contoso.lab |
| Object type | user → person |
| Link type | Join |
| Precedence | 50 |
| Target attribute | cloudFiltered |
| Flow type | Expression |
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))employeeType | extensionAttribute1 | cloudFiltered | Outcome |
|---|---|---|---|
Interno | anything | False | synchronises |
| other | SYNC365 | False | synchronises |
| other | other or absent | True | excluded |
| absent | SYNC365 | False | synchronises |
| absent | absent | True | excluded |
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
This applies from here on, permanently.
- Enable staging mode
- Change or create the rule in the editor
- Run an
Initialcycle - Inspect the pending operations (Pending Export)
- Correct until no unwanted deletions appear
- Only then disable staging
- Run another
Initialcycle - 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
The tool that answers in thirty seconds:
& "C:\Program Files\Microsoft Azure AD Sync\UIShell\miisclient.exe"Connectors → the forest connector → Search Connector Space → Scope: DN → the object's distinguished name → Preview → Generate Preview → Import 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
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
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| What | Expected |
|---|---|
| Cluster nodes | Both Up |
| Witness | Online |
| Database copies | Two Mounted per node, the rest Healthy |
| Replication health | No output |
| Queues | Empty, except Shadow |
Remedy:
Start-ClusterGroup "Cluster Group"#Taking a user all the way to the cloud
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 "[email protected]" `
-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 [email protected]
# 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 -AutoSizeIf 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
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).