Migrating NSX Distributed Firewall Policies the Right Way: A PowerShell Toolkit
When our team was handed the task of migrating a large NSX environment (that was migrated several times) to NSX 9, I quickly realized that the built-in tooling wasn’t going to cut it for what we needed. The migration involved hundreds of custom security groups, firewall policies, service definitions, and VM tags — all interconnected in ways that made manual recreation completely impractical. So I did what any self-respecting infrastructure engineer would do: I built a set of PowerShell scripts to automate the whole thing.
What started as a quick export utility eventually grew into a five-script toolkit that handles export, sanitization, import, and rollback — all with proper dependency ordering, conflict resolution, logging, and dry-run support. This post walks through how the toolkit works, the design decisions behind it, and the lessons I learned along the way.
The Problem With NSX Migrations
NSX Distributed Firewall (DFW) configurations are deceptively complex. On the surface, you have policies, rules, groups, services, and IP sets. But underneath, everything references everything else. A firewall rule references security groups. A security group may contain other security groups. A service group references individual services. If you try to delete or recreate objects in the wrong order, the API throws dependency errors. If you try to import a group before the group it references has been created, you get a 404. The order matters enormously, and there’s no built-in tooling that handles this gracefully across a major version migration.
There’s also a subtler problem I discovered early on: older object IDs that date from the NSX-V era are ugly internal identifiers like securitygroup-223 or ipset-286, while the human-readable display names are things like Datacenter or L2-Infra-ICT-management. When you export from and try to import into NSX 9, these IDs get preserved as-is, which means your destination environment ends up full of meaningless internal identifiers instead of the clean names you actually want. Fixing this after the fact is a nightmare, so I built a sanitization layer to handle it before import.
The Toolkit: Five Scripts, One Pipeline
The toolkit consists of five PowerShell scripts that are designed to be run in sequence:
- Export-NSX-DFW.ps1 — exports all DFW objects from an NSX 4 manager to CSV files
- Sanitize-NSX.ps1 — orchestrates the sanitization pipeline
- Sanitize-NSXGroups.ps1 — renames group IDs to match display names and updates cross-references
- Sanitize-NSXFirewallRules.ps1 — updates firewall rule group references after group IDs are renamed
- Import-NSX-DFW.ps1 — imports sanitized CSV files into an NSX 9 manager
- Remove-NSX-ImportedObjects.ps1 — rolls back an import using the original CSV files
- Remove-NSX-AllCustomObjects.ps1 — removes all custom objects directly from inventory without needing CSV files
Yes, I realize I said five scripts and then listed seven. The two removal scripts grew out of operational necessity once I realized rollback support was essential for a production migration.
Step 1: Export Everything
The export script (Export-NSX-DFW.ps1) connects to the source NSX 4 manager and dumps all DFW objects to CSV. Each object type gets its own file:
NSX_IPSets.csvNSX_Services.csvNSX_ServiceGroups.csvNSX_Groups.csvNSX_Policies.csvNSX_Rules.csvNSX_VMTags.csv
Every CSV row contains readable columns for review purposes — things like display name, description, sequence number, action, source/destination groups — plus a RawJson column that contains the complete, serialized API payload for that object. The import script only looks at RawJson; all other columns exist purely for human review.
This design was deliberate. It means you can open the CSV in Excel, review what’s going to be imported, delete rows for objects you want to exclude, and the import will just skip them. It’s a simple but powerful way to let engineers make judgment calls about what gets migrated without needing to modify any script code.
The export script also strips read-only fields from the JSON before saving it — things like _create_time, _last_modified_time, _system_owned, and _revision. These fields are set by NSX and will cause a 400 error if you try to submit them on a PATCH request.
All export types are opt-in and default to $false. You enable what you want:
.\Export-NSX-DFW.ps1 -NSXManager nsxold.corp.local `
-ExportIPSets $true -ExportServices $true `
-ExportGroups $true -ExportPolicies $true `
-ExportTags $true
Step 2: Sanitize the Export
This is the step I spent the most time on, and it’s the one that saves the most headaches downstream.
The core problem is that exports originally coming from NSX-V use internal identifiers as object IDs. So a group named Datacenter might have an ID of securitygroup-223, and a group named L2-Infra-ICT-management might have an ID of securitygroup-70. When this data lands in NSX 9, you end up with objects identified by these opaque legacy strings instead of their meaningful names. Future administrators will have no idea what securitygroup-223 refers to without cross-referencing documentation.
My solution was to build a sanitization pipeline that renames every group ID to match its display name, then propagates those renames through every other file that contains cross-references to those groups.
How Sanitize-NSXGroups.ps1 Works
The groups script does three things for each group row:
First, it builds a complete ID mapping table by scanning every row up front. This is important — you need the full map before you start rewriting, because a group later in the file might be referenced by a group earlier in the file. Building the map in a separate pass before applying it ensures all cross-references get resolved correctly.
Second, it handles the awkward reality that NSX group IDs have character restrictions. IDs can only contain letters, digits, hyphens, and underscores. Display names, on the other hand, can contain spaces, slashes, parentheses, dots, and all sorts of other characters. My Sanitize-Id function replaces any disallowed character with a dash before using the display name as the new ID.
Third, it handles duplicate display names. If two groups both have the display name Datacenter (it happens more than you’d think), the script detects the collision and appends numeric suffixes: Datacenter-1, Datacenter-2. A warning is logged so the engineer can review the disambiguation.
Once the mapping table is built, the script applies it in several passes:
- Updates the
Idcolumn in the CSV - Rewrites
/groups/<oldId>path segments throughoutRawJson— this is how NSX embeds group references inside other objects - Updates the object’s own
"id"and"relative_path"JSON fields - Strips all
"tags"arrays fromRawJson(these are NSX-V migration artefacts with no value in the destination)
One edge case I had to handle carefully: NSX sometimes emits Unicode escape sequences in JSON, like \u0027 for a single quote. If a display name contains a single quote, the exported JSON would have \u0027 in it instead of ', and a naive regex substitution would never match the ID. I added a Decode-UnicodeEscapes function that converts all such sequences to their actual characters before any substitution runs.
Another edge case: when sorting the ID mapping keys for substitution, I sort by key length descending. This prevents a shorter key from matching as a prefix inside a longer key at a path boundary. For example, if you have groups with IDs IPNET_DATA-SER4 and IPNET_DATA-SER4/4A (yes, slashes in IDs are a real thing), substituting the shorter one first would corrupt the longer reference.
How Sanitize-NSXFirewallRules.ps1 Works
After the groups are sanitized, firewall rules still reference groups using the old IDs embedded in NSX path strings like /infra/domains/default/groups/securitygroup-223. The rules sanitization script takes the same ID mapping table and applies it to every group reference in the rules CSV — in the SourceGroups, DestGroups, and AppliedTo columns, and inside the corresponding JSON arrays in RawJson.
The orchestrator script (Sanitize-NSX.ps1) coordinates both steps, passing the ID mapping table as a live PowerShell hashtable from the groups script directly to the rules script, so there’s no intermediate file read required.
Step 3: Import into NSX 9
The import script (Import-NSX-DFW.ps1) reads the sanitized CSV files and PATCHes each object into the destination NSX 9 manager.
Dependency Ordering
The most important design constraint for the import is dependency ordering. You can’t create a security group that references another security group if the referenced group doesn’t exist yet. You can’t create a firewall rule that references a group if the group hasn’t been created. The import order matters:
- IP Sets (no dependencies)
- Services (no dependencies)
- Service Groups (depend on Services)
- Security Groups (may depend on other Security Groups)
- Policies and Rules (depend on Groups and Services)
IP sets and plain services are straightforward because they have no dependencies on other custom objects. Service groups are slightly more complex because a service group can reference other service groups, so I apply a topological sort before importing them.
Security groups are the most challenging. NSX groups can reference other groups in two ways: through NestedExpression (which embeds another group’s membership criteria directly) and through PathExpression (which references another group by its full NSX path). I parse the RawJson of each group to extract these dependencies, then run a topological sort using an iterative post-order DFS to determine the correct creation order.
I specifically chose an iterative DFS over a recursive one because PowerShell’s default recursion depth limit is relatively shallow, and a deeply nested group hierarchy in a real production environment could easily exceed it. The iterative implementation uses an explicit stack to avoid this.
Conflict Resolution
When an object already exists on the destination, the script uses the -ConflictAction parameter to decide what to do:
- Skip (default): log a warning and move on
- Overwrite: PATCH the existing object with the new payload
- Prompt: ask the operator interactively for each conflict
- Abort: throw an error and stop the import
For most migrations I run with Skip first to get a baseline inventory, then with Overwrite once I’m satisfied the data is correct.
VM Tags
VM tags get special treatment. Unlike DFW policy objects, tags are applied to VMs that must already exist in the destination vCenter/NSX inventory — the script can’t create VMs. Before applying tags to any VM, the import script verifies that the VM’s external ID (the vCenter UUID) exists in the destination NSX fabric inventory. If it doesn’t, the VM is skipped with a warning.
All tag operations for a given VM are batched into a single API call using the update_tags action, which is more efficient than one call per tag.
Step 4: Rollback If Needed
Production migrations go sideways. Having a clean rollback path is not optional.
The Remove-NSX-ImportedObjects.ps1 script reads the same CSV files that the import used and deletes the corresponding objects from the destination. It respects the reverse dependency order — policies are deleted before groups, groups before service groups, service groups before services, services before IP sets.
Like the import script, the removal script supports -WhatIf for dry runs, and it handles the case where an object doesn’t exist on the destination (maybe it was already removed manually) by logging a “NOT FOUND” warning and continuing rather than failing.
I almost always run a dry run first:
.\Remove-NSX-ImportedObjects.ps1 -NSXManager nsx9.corp.local `
-InputFolder .\NSX_DFW_Export_20250101_120000 `
-RemovePolicies $true -RemoveGroups $true `
-RemoveServiceGroups $true -RemoveServices $true `
-RemoveIPSets $true -WhatIf
The Inventory-Based Cleanup Script
During testing, I found myself repeatedly importing, discovering a problem, rolling back, fixing the export, and trying again. After a few cycles of this, I got tired of needing the original CSV files to do a rollback. What if I just wanted to wipe all custom objects from the destination and start fresh?
That’s what Remove-NSX-AllCustomObjects.ps1 is for. It queries the live NSX inventory, finds all non-system-owned objects, and deletes them in dependency order — without needing any CSV files. It filters out system-owned objects using the _system_owned property that NSX includes in every object’s metadata, so built-in NSX policies and system services are never touched.
It also includes an optional VM tag cleanup feature (-ClearVMTags). When enabled, it prompts for an optional scope filter — for example, you can specify env to clear only tags with that scope, leaving unrelated tags on the VMs untouched. If you don’t provide a scope filter, it will clear all tags on all VMs, but it asks for an explicit YES confirmation before doing so. However, it is not expected to have VMs already in your new environment when the DFW is not migrated yet, but it is an option.
Logging
All seven scripts use a shared Write-Log function with three output modes:
- Screen: colored console output (INFO = cyan, WARN = yellow, ERROR = red, SUCCESS = green)
- File: writes to a log file only, no console output — useful for scheduled or unattended runs
- Both: colored console output and log file simultaneously
Every log line is timestamped and level-tagged, so the log files are immediately useful for post-migration auditing:
[2025-01-01 14:23:45][SUCCESS] ✔ Deleted Policy: pol-web-tier (Web Tier Policy) — 12 rule(s)
[2025-01-01 14:23:46][WARN] NOT FOUND: Group 'legacy-dmz' — already removed or never imported.
[2025-01-01 14:23:47][ERROR] DELETE https://nsx9.corp.local/policy/api/v1/... failed: 409 Conflict
A Few Things I Learned the Hard Way
Always run -WhatIf first. The scripts support PowerShell’s native -WhatIf parameter through SupportsShouldProcess, which means every destructive operation shows you exactly what it would do before committing.
The topological sort is worth the complexity. Early versions of the script just imported groups in CSV row order. That worked fine until I hit a customer environment with deeply nested group hierarchies, and then it failed spectacularly with dependency errors. The iterative DFS sort added maybe 150 lines of code and eliminated an entire class of import failures.
Pagination matters at scale. The Get-AllPages function handles NSX’s cursor-based pagination for all list endpoints. In small environments you’d never notice this is needed. In a production environment with 400+ security groups, you absolutely do.
Unicode escapes in JSON are a real edge case. I didn’t encounter this until I processed an export from a customer environment where someone had named a group with a single quote in the name. The NSX API returned it as \u0027 in the JSON, and the regex substitutions silently failed until I added the decode step. Always decode before matching.
System-owned objects need to be respected. NSX marks built-in objects with _system_owned: true. Every query result needs to be filtered against this property before doing any deletion or modification. The first version of the cleanup script didn’t filter properly and attempted to delete some default NSX security policies — the API correctly rejected this, but it caused confusing error messages until I added the filter.
What’s Next
There are a few areas where the toolkit could be extended:
The export script currently excludes Gateway Firewall policies, focusing only on DFW. A Gateway Firewall export module would follow the same pattern but target different API endpoints and would need to handle service router and Tier-0/Tier-1 topology dependencies.
The sanitization pipeline currently only covers groups and rules. If you have services or policies with display name / ID mismatches (less common but possible), a Sanitize-NSXServices.ps1 module could be added following the same template.
For very large environments, the serial API calls are a bottleneck. PowerShell 7 supports ForEach-Object -Parallel, which could dramatically speed up the export and verification phases. The import and deletion phases need to remain sequential because of dependency ordering, but independent object types could be parallelized across each other.
Wrapping Up
Migrating NSX DFW configurations is one of those tasks that looks straightforward until you’re deep in it and realize that “just re-creating the objects” involves navigating a web of dependencies, legacy IDs, API quirks, and production risk. Building a proper toolset — with dependency ordering, dry-run support, conflict resolution, rollback capability, and sanitization — turned what would have been days of manual work into a reliable, repeatable, auditable pipeline.
If you’re facing a similar migration, I hope this walkthrough is useful. The scripts are designed to be read and adapted — every function is documented, every non-obvious design decision has a comment explaining why it was made that way. Feel free to take them, extend them, and adapt them to your environment.
And always run -WhatIf first.