Creating PowerShell modules is only half the journey—packaging and deploying them properly ensures your code reaches users reliably and maintains quality standards. This comprehensive guide covers the complete lifecycle of PowerShell module distribution, from writing robust Pester tests to publishing on PSGallery.

Understanding PowerShell Module Structure

Before diving into packaging and deployment, understanding the proper module structure is essential. A well-organized module follows PowerShell conventions and includes all necessary components for testing and distribution.

Essential Module Components

A production-ready PowerShell module typically contains these elements:

  • Module manifest (.psd1) – Metadata file defining module properties, dependencies, and exported functions
  • Root module (.psm1) – Main script file containing functions and logic
  • Public/Private folders – Organized function separation for exported and internal functions
  • Tests folder – Pester test files for validation
  • README.md – Documentation for users and contributors
  • LICENSE – Licensing information

Packaging and Deploying PowerShell Modules: Complete Guide to Pester Tests, NuGet, and PSGallery Publishing

# Example module structure
MyModule/
├── MyModule.psd1          # Module manifest
├── MyModule.psm1          # Root module
├── Public/
│   ├── Get-UserData.ps1
│   └── Set-UserConfig.ps1
├── Private/
│   └── Invoke-Helper.ps1
├── Tests/
│   ├── MyModule.Tests.ps1
│   └── Get-UserData.Tests.ps1
├── README.md
└── LICENSE

Creating the Module Manifest

The module manifest is the cornerstone of your module, providing metadata and configuration. Use New-ModuleManifest to generate it:

# Create a new module manifest
New-ModuleManifest -Path .\MyModule\MyModule.psd1 `
    -RootModule 'MyModule.psm1' `
    -ModuleVersion '1.0.0' `
    -Author 'Your Name' `
    -Description 'A powerful module for data management' `
    -PowerShellVersion '5.1' `
    -FunctionsToExport @('Get-UserData', 'Set-UserConfig') `
    -CmdletsToExport @() `
    -VariablesToExport @() `
    -AliasesToExport @() `
    -Tags @('Data', 'Management', 'Utility') `
    -ProjectUri 'https://github.com/username/MyModule' `
    -LicenseUri 'https://github.com/username/MyModule/blob/main/LICENSE'

Output:

# Successfully created module manifest at: C:\MyModule\MyModule.psd1

Writing Comprehensive Pester Tests

Pester is PowerShell’s testing framework that ensures your module functions correctly across different scenarios. Writing thorough tests before deployment prevents bugs from reaching users.

Installing Pester

# Install latest Pester version
Install-Module -Name Pester -Force -SkipPublisherCheck

# Verify installation
Get-Module -Name Pester -ListAvailable

Output:

    Directory: C:\Program Files\WindowsPowerShell\Modules

ModuleType Version    Name                                ExportedCommands
---------- -------    ----                                ----------------
Script     5.5.0      Pester                              {Invoke-Pester, Describe, Context...}

Basic Pester Test Structure

Pester 5.x uses a structured approach with BeforeAll, Describe, Context, and It blocks:

# Tests/MyModule.Tests.ps1
BeforeAll {
    # Import the module
    $ModulePath = Split-Path -Parent $PSScriptRoot
    Import-Module "$ModulePath\MyModule.psd1" -Force
}

Describe "MyModule Tests" {
    Context "Module Import" {
        It "Should import successfully" {
            $Module = Get-Module -Name MyModule
            $Module | Should -Not -BeNullOrEmpty
        }
        
        It "Should export expected functions" {
            $Commands = Get-Command -Module MyModule
            $Commands.Name | Should -Contain 'Get-UserData'
            $Commands.Name | Should -Contain 'Set-UserConfig'
        }
    }
}

Testing Individual Functions

Create dedicated test files for each public function with comprehensive coverage:

# Tests/Get-UserData.Tests.ps1
BeforeAll {
    $ModulePath = Split-Path -Parent $PSScriptRoot
    Import-Module "$ModulePath\MyModule.psd1" -Force
}

Describe "Get-UserData" {
    Context "Parameter Validation" {
        It "Should accept valid username" {
            { Get-UserData -Username "john.doe" } | Should -Not -Throw
        }
        
        It "Should throw on empty username" {
            { Get-UserData -Username "" } | Should -Throw
        }
        
        It "Should accept pipeline input" {
            $result = "jane.doe" | Get-UserData
            $result | Should -Not -BeNullOrEmpty
        }
    }
    
    Context "Return Values" {
        It "Should return PSCustomObject" {
            $result = Get-UserData -Username "test.user"
            $result | Should -BeOfType [PSCustomObject]
        }
        
        It "Should include required properties" {
            $result = Get-UserData -Username "test.user"
            $result.PSObject.Properties.Name | Should -Contain 'Username'
            $result.PSObject.Properties.Name | Should -Contain 'Email'
            $result.PSObject.Properties.Name | Should -Contain 'Status'
        }
    }
    
    Context "Error Handling" {
        It "Should handle non-existent users gracefully" {
            { Get-UserData -Username "nonexistent.user" -ErrorAction Stop } | Should -Throw
        }
    }
}

Advanced Testing Techniques

Use mocking to test functions that interact with external systems without requiring actual connections:

# Tests/Get-UserData.Tests.ps1 (continued)
Describe "Get-UserData with Mocking" {
    Context "External API Calls" {
        BeforeAll {
            # Mock external API call
            Mock Invoke-RestMethod {
                return @{
                    Username = "test.user"
                    Email = "[email protected]"
                    Status = "Active"
                }
            } -ModuleName MyModule
        }
        
        It "Should call API with correct parameters" {
            Get-UserData -Username "test.user"
            Should -Invoke Invoke-RestMethod -ModuleName MyModule -Times 1 -Exactly
        }
        
        It "Should process API response correctly" {
            $result = Get-UserData -Username "test.user"
            $result.Email | Should -Be "[email protected]"
        }
    }
}

Running Pester Tests

# Run all tests in the Tests folder
Invoke-Pester -Path .\MyModule\Tests

# Run tests with code coverage
$config = New-PesterConfiguration
$config.Run.Path = ".\MyModule\Tests"
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = ".\MyModule\*.psm1", ".\MyModule\Public\*.ps1"
$config.Output.Verbosity = "Detailed"
Invoke-Pester -Configuration $config

Output:

Starting discovery in 3 files.
Discovery found 12 tests in 245ms.
Running tests.
[+] MyModule Tests 1.45s (1.23s|218ms)
  [+] Module Import 124ms (45ms|79ms)
    [+] Should import successfully 67ms (65ms|2ms)
    [+] Should export expected functions 57ms (55ms|2ms)
  [+] Get-UserData 1.32s (1.18s|139ms)
    [+] Parameter Validation 456ms (398ms|58ms)
    [+] Return Values 534ms (489ms|45ms)
    [+] Error Handling 330ms (293ms|37ms)

Tests completed in 1.45s
Tests Passed: 12, Failed: 0, Skipped: 0, Total: 12
Code Coverage: 87.5% (35/40 lines covered)

Packaging Modules for Distribution

Once your module passes all tests, proper packaging ensures consistent deployment across different environments and platforms.

Packaging and Deploying PowerShell Modules: Complete Guide to Pester Tests, NuGet, and PSGallery Publishing

Version Management

Follow semantic versioning (SemVer) for clear version communication:

# Update module version in manifest
$ManifestPath = ".\MyModule\MyModule.psd1"
$Manifest = Import-PowerShellDataFile -Path $ManifestPath

# Increment version (example: 1.0.0 to 1.1.0)
$CurrentVersion = [version]$Manifest.ModuleVersion
$NewVersion = "{0}.{1}.{2}" -f $CurrentVersion.Major, ($CurrentVersion.Minor + 1), 0

# Update manifest with new version
Update-ModuleManifest -Path $ManifestPath -ModuleVersion $NewVersion
Write-Host "Updated module version to $NewVersion" -ForegroundColor Green

Creating NuGet Packages

PowerShell modules can be packaged as NuGet packages for distribution through custom feeds:

# Install required tools
Install-Module -Name PowerShellGet -Force

# Create a NuGet package specification
$nuspecContent = @"


  
    MyModule
    1.1.0
    Your Name
    Your Name
    false
    A powerful module for data management
    PowerShell Data Management
    
      
        
      
    
  

"@

$nuspecContent | Out-File -FilePath ".\MyModule\MyModule.nuspec" -Encoding utf8

# Build NuGet package using nuget.exe
nuget pack .\MyModule\MyModule.nuspec -OutputDirectory .\packages

Output:

Attempting to build package from 'MyModule.nuspec'.
Successfully created package 'C:\packages\MyModule.1.1.0.nupkg'.

Code Signing

Sign your module scripts to establish trust and prevent tampering:

# Get code signing certificate
$cert = Get-ChildItem -Path Cert:\CurrentUser\My -CodeSigningCert | Select-Object -First 1

# Sign all script files
Get-ChildItem -Path .\MyModule -Include *.ps1,*.psm1,*.psd1 -Recurse | ForEach-Object {
    Set-AuthenticodeSignature -FilePath $_.FullName -Certificate $cert -TimestampServer "http://timestamp.digicert.com"
}

Output:

    Directory: C:\MyModule

SignerCertificate                Status  Path
-----------------                ------  ----
1A2B3C4D5E6F7G8H9I0J             Valid   MyModule.psm1
1A2B3C4D5E6F7G8H9I0J             Valid   MyModule.psd1
1A2B3C4D5E6F7G8H9I0J             Valid   Get-UserData.ps1

Publishing to PowerShell Gallery

PowerShell Gallery (PSGallery) is the primary repository for sharing PowerShell modules with the community. Publishing requires an API key and proper module configuration.

Preparing for PSGallery Publication

Ensure your module meets PSGallery requirements:

  • Unique module name not already published
  • Valid module manifest with required metadata
  • LicenseUri and ProjectUri specified
  • Descriptive tags for discoverability
  • Release notes for version updates
# Validate module before publishing
Test-ModuleManifest -Path .\MyModule\MyModule.psd1

# Check module structure
$moduleInfo = Import-Module .\MyModule\MyModule.psd1 -PassThru
$moduleInfo | Select-Object Name, Version, ExportedCommands

Output:

Name      Version ExportedCommands
----      ------- ----------------
MyModule  1.1.0   {Get-UserData, Set-UserConfig}

Test passed successfully. Module is ready for publication.

Obtaining PSGallery API Key

Register and get your API key from PowerShell Gallery:

  1. Visit https://www.powershellgallery.com
  2. Sign in with your Microsoft account
  3. Navigate to Account Settings
  4. Generate an API key with appropriate expiration
  5. Store securely (never commit to source control)
# Store API key securely
$apiKey = Read-Host -Prompt "Enter PSGallery API Key" -AsSecureString
$credential = New-Object System.Management.Automation.PSCredential("APIKey", $apiKey)

Publishing Your Module

# Publish to PSGallery
$publishParams = @{
    Path        = ".\MyModule"
    NuGetApiKey = $apiKey
    Repository  = "PSGallery"
    Verbose     = $true
}

Publish-Module @publishParams

Output:

VERBOSE: Performing the operation "Publish-Module" on target "Version '1.1.0' of module 'MyModule'".
VERBOSE: Validating the module at path 'C:\MyModule'.
VERBOSE: Module validation succeeded.
VERBOSE: Publishing module 'MyModule' to repository 'PSGallery'.
VERBOSE: Module 'MyModule' version '1.1.0' successfully published to PSGallery.

Updating Published Modules

When releasing updates, increment the version and publish again:

# Update version in manifest
Update-ModuleManifest -Path .\MyModule\MyModule.psd1 `
    -ModuleVersion "1.2.0" `
    -ReleaseNotes "Added new feature: Bulk user processing, Fixed bug in email validation"

# Run tests before publishing
Invoke-Pester -Path .\MyModule\Tests

# Publish updated version
Publish-Module -Path .\MyModule -NuGetApiKey $apiKey

Setting Up Private NuGet Repositories

For enterprise environments or private modules, hosting a private NuGet feed provides controlled distribution.

Packaging and Deploying PowerShell Modules: Complete Guide to Pester Tests, NuGet, and PSGallery Publishing

Creating a File-Based Repository

The simplest private repository uses a network file share:

# Create repository directory
$repoPath = "\\server\share\PSRepository"
New-Item -Path $repoPath -ItemType Directory -Force

# Register the repository
Register-PSRepository -Name "CompanyRepo" `
    -SourceLocation $repoPath `
    -PublishLocation $repoPath `
    -InstallationPolicy Trusted

# Verify registration
Get-PSRepository -Name "CompanyRepo"

Output:

Name                      InstallationPolicy   SourceLocation
----                      ------------------   --------------
CompanyRepo               Trusted              \\server\share\PSRepository

Publishing to Private Repository

# Publish module to private repository
Publish-Module -Path .\MyModule -Repository CompanyRepo

# Users can now install from the private repository
Install-Module -Name MyModule -Repository CompanyRepo

Azure Artifacts Setup

Azure DevOps provides robust package management for enterprise scenarios:

# Register Azure Artifacts feed
$feedUrl = "https://pkgs.dev.azure.com/yourorg/_packaging/yourfeed/nuget/v2"
$patToken = ConvertTo-SecureString "your-pat-token" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential("AzureDevOps", $patToken)

Register-PSRepository -Name "AzureArtifacts" `
    -SourceLocation $feedUrl `
    -PublishLocation $feedUrl `
    -InstallationPolicy Trusted `
    -Credential $credential

# Publish with authentication
Publish-Module -Path .\MyModule `
    -Repository AzureArtifacts `
    -NuGetApiKey $patToken `
    -Credential $credential

Continuous Integration and Deployment

Automating your module deployment pipeline ensures consistency and reduces manual errors.

GitHub Actions Workflow

# .github/workflows/publish.yml
name: Publish Module

on:
  push:
    tags:
      - 'v*'

jobs:
  publish:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Install Pester
        shell: pwsh
        run: Install-Module -Name Pester -Force -SkipPublisherCheck
      
      - name: Run Tests
        shell: pwsh
        run: Invoke-Pester -Path .\Tests -CI
      
      - name: Publish to PSGallery
        shell: pwsh
        env:
          NUGET_API_KEY: ${{ secrets.PSGALLERY_API_KEY }}
        run: |
          Publish-Module -Path .\MyModule -NuGetApiKey $env:NUGET_API_KEY

Azure DevOps Pipeline

# azure-pipelines.yml
trigger:
  tags:
    include:
      - v*

pool:
  vmImage: 'windows-latest'

steps:
- task: PowerShell@2
  displayName: 'Install Dependencies'
  inputs:
    targetType: 'inline'
    script: |
      Install-Module -Name Pester -Force -SkipPublisherCheck
      Install-Module -Name PSScriptAnalyzer -Force

- task: PowerShell@2
  displayName: 'Run Pester Tests'
  inputs:
    targetType: 'inline'
    script: |
      $config = New-PesterConfiguration
      $config.Run.Path = ".\Tests"
      $config.Output.Verbosity = "Detailed"
      $config.TestResult.Enabled = $true
      Invoke-Pester -Configuration $config

- task: PublishTestResults@2
  displayName: 'Publish Test Results'
  inputs:
    testResultsFormat: 'NUnit'
    testResultsFiles: '**/testResults.xml'

- task: PowerShell@2
  displayName: 'Publish Module'
  inputs:
    targetType: 'inline'
    script: |
      Publish-Module -Path .\MyModule -NuGetApiKey $(PSGalleryApiKey)

Best Practices and Recommendations

Module Development Checklist

  • Write comprehensive Pester tests covering all functions and edge cases
  • Maintain test coverage above 80% for critical code paths
  • Use semantic versioning consistently across releases
  • Document all public functions with comment-based help
  • Include examples in function help for user reference
  • Sign your code with a valid certificate for trust
  • Keep dependencies minimal and well-documented
  • Test on multiple PowerShell versions (5.1, 7.x)

Security Considerations

# Never include sensitive data in your module
# Use secure parameter types
function Set-ServiceCredential {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$ServiceName,
        
        [Parameter(Mandatory)]
        [SecureString]$Password
    )
    
    # Convert SecureString for use
    $credential = New-Object System.Management.Automation.PSCredential("user", $Password)
    # Use credential safely
}

# Validate all user input
function Get-UserData {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidatePattern('^[a-zA-Z0-9.]+$')]
        [string]$Username
    )
    
    # Function implementation
}

Performance Optimization

# Use proper scoping to avoid pollution
$script:CachedData = @{}

function Get-CachedUserData {
    param([string]$Username)
    
    if ($script:CachedData.ContainsKey($Username)) {
        return $script:CachedData[$Username]
    }
    
    $data = Get-UserData -Username $Username
    $script:CachedData[$Username] = $data
    return $data
}

# Implement efficient pipeline processing
function Process-Users {
    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline)]
        [string]$Username
    )
    
    begin {
        # Initialize once
        $results = [System.Collections.Generic.List[object]]::new()
    }
    
    process {
        # Process each item efficiently
        $data = Get-UserData -Username $Username
        $results.Add($data)
    }
    
    end {
        # Return all results
        return $results
    }
}

Troubleshooting Common Issues

Module Not Loading

# Check module paths
$env:PSModulePath -split ';'

# Verify manifest validity
Test-ModuleManifest -Path .\MyModule\MyModule.psd1 -Verbose

# Force reimport during development
Remove-Module MyModule -ErrorAction SilentlyContinue
Import-Module .\MyModule\MyModule.psd1 -Force -Verbose

Publishing Failures

# Common issues and solutions:

# Issue: Module name conflict
Find-Module -Name MyModule -Repository PSGallery
# Solution: Choose a unique name

# Issue: Invalid manifest
Test-ModuleManifest -Path .\MyModule\MyModule.psd1
# Solution: Fix reported errors

# Issue: Missing required fields
$manifest = Import-PowerShellDataFile .\MyModule\MyModule.psd1
$manifest.Keys
# Solution: Add ProjectUri, LicenseUri, Tags

# Issue: Network/Authentication errors
Test-NetConnection -ComputerName www.powershellgallery.com -Port 443
# Solution: Check firewall, proxy, API key validity

Test Failures

# Debug failing tests
$config = New-PesterConfiguration
$config.Run.Path = ".\Tests"
$config.Output.Verbosity = "Diagnostic"
$config.Debug.WriteDebugMessages = $true
Invoke-Pester -Configuration $config

# Run specific test
Invoke-Pester -Path .\Tests\Get-UserData.Tests.ps1 -TagFilter "Validation"

# Check for module loading issues in tests
BeforeAll {
    $ModulePath = Split-Path -Parent $PSScriptRoot
    Remove-Module MyModule -ErrorAction SilentlyContinue
    Import-Module "$ModulePath\MyModule.psd1" -Force -Verbose
}

Advanced Deployment Scenarios

Packaging and Deploying PowerShell Modules: Complete Guide to Pester Tests, NuGet, and PSGallery Publishing

Multi-Environment Deployment

# Deploy to different environments based on branch
param(
    [Parameter(Mandatory)]
    [ValidateSet('Development', 'Staging', 'Production')]
    [string]$Environment
)

$config = @{
    Development = @{
        Repository = "DevRepo"
        RequireTests = $false
        AutoDeploy = $true
    }
    Staging = @{
        Repository = "StagingRepo"
        RequireTests = $true
        AutoDeploy = $true
    }
    Production = @{
        Repository = "PSGallery"
        RequireTests = $true
        AutoDeploy = $false
        RequireApproval = $true
    }
}

$envConfig = $config[$Environment]

if ($envConfig.RequireTests) {
    $testResults = Invoke-Pester -Path .\Tests -PassThru
    if ($testResults.FailedCount -gt 0) {
        throw "Tests failed. Cannot deploy to $Environment"
    }
}

if ($envConfig.AutoDeploy) {
    Publish-Module -Path .\MyModule -Repository $envConfig.Repository
    Write-Host "Deployed to $Environment" -ForegroundColor Green
} else {
    Write-Host "Manual approval required for $Environment deployment" -ForegroundColor Yellow
}

Version Rollback Strategy

# Implement rollback capability
function Restore-ModuleVersion {
    param(
        [string]$ModuleName,
        [version]$Version,
        [string]$Repository = "PSGallery"
    )
    
    # Uninstall current version
    Uninstall-Module -Name $ModuleName -AllVersions -Force
    
    # Install specific version
    Install-Module -Name $ModuleName -RequiredVersion $Version -Repository $Repository -Force
    
    Write-Host "Rolled back $ModuleName to version $Version" -ForegroundColor Green
}

# Usage example
Restore-ModuleVersion -ModuleName "MyModule" -Version "1.0.0"

Monitoring and Analytics

Tracking Module Usage

# Add telemetry to your module (optional, with user consent)
function Send-ModuleTelemetry {
    param(
        [string]$FunctionName,
        [hashtable]$Parameters
    )
    
    if ($script:TelemetryEnabled) {
        $telemetry = @{
            Module = "MyModule"
            Function = $FunctionName
            Version = (Get-Module MyModule).Version.ToString()
            Timestamp = Get-Date -Format "o"
            PSVersion = $PSVersionTable.PSVersion.ToString()
        }
        
        # Send to your analytics endpoint
        Invoke-RestMethod -Uri "https://analytics.example.com/track" `
            -Method Post -Body ($telemetry | ConvertTo-Json) `
            -ContentType "application/json" `
            -ErrorAction SilentlyContinue
    }
}

Successfully packaging and deploying PowerShell modules requires attention to testing, versioning, and distribution channels. By following these practices and leveraging automation tools, you ensure your modules reach users reliably while maintaining high quality standards. Regular testing with Pester, proper semantic versioning, and strategic use of repositories like PSGallery or private NuGet feeds create a professional deployment pipeline that scales with your organization’s needs.