How to Build PowerShell Script Modules on Windows Server 2012 R2

PowerShell script modules are the standard packaging mechanism for distributing reusable functions, workflows, and cmdlets within an organization. A script module is simply a .psm1 file — a PowerShell script that defines functions and controls which of them are exported for use by consumers of the module. Combined with a module manifest (.psd1 file), script modules support versioning, dependency declarations, and rich metadata that enables proper governance of a growing PowerShell function library.

Windows Server 2012 R2 with PowerShell 4.0 introduced module autoloading, meaning any function in a module placed in a PSModulePath directory is automatically imported the first time you call one of its exported functions — no explicit Import-Module statement required. This makes well-structured modules extremely convenient to use. This guide covers building modules from first principles: authoring .psm1 files, creating manifests, writing Comment-Based Help, controlling exports, and organizing functions for a real-world server administration module.

Prerequisites

– Windows Server 2012 R2 with PowerShell 4.0
– A text editor (PowerShell ISE, Notepad++, or Visual Studio Code)
– Administrative credentials
– Familiarity with PowerShell functions, parameters, and pipelines

Step 1: Understand Module Types and Structure

# PowerShell 4.0 supports these module types:
# Script modules (.psm1)       - Functions written in PowerShell (most common)
# Binary modules (.dll)        - Compiled C# cmdlets
# Manifest modules (.psd1)     - Metadata file pointing to root module
# Dynamic modules              - Created in memory with New-Module

# Minimum valid module: a single .psm1 file in a correctly named folder
# Module folder structure:
#   C:PowerShellModules
#   └── ServerAdmin               ← folder name must match module name
#       ├── ServerAdmin.psm1       ← root module (required)
#       ├── ServerAdmin.psd1       ← manifest (recommended)
#       └── Private               ← optional: private helper functions
#           └── Helpers.ps1

# List all available modules and their versions
Get-Module -ListAvailable | Sort-Object Name | Select-Object Name, Version, Path

# Verify PSModulePath includes your custom modules directory
$env:PSModulePath -split ";"

Step 2: Create the Module Directory Structure

# Create the module folder (name must exactly match the .psm1 filename)
$moduleName = "ServerAdmin"
$modulePath = "C:PowerShellModules$moduleName"

New-Item -Path "$modulePathPrivate" -ItemType Directory -Force

# Verify directory structure
Get-ChildItem $modulePath -Recurse

Step 3: Author the Module File with Comment-Based Help

# Create the .psm1 file with functions, private helpers, and Comment-Based Help
$psm1Content = @'
#Requires -Version 4.0
# ServerAdmin.psm1
# Module for Windows Server 2012 R2 administration utilities

# Dot-source private helper functions
. "$PSScriptRootPrivateHelpers.ps1"


function Test-ServerHealth {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
        [string[]]$ComputerName,

        [Parameter()]
        [PSCredential]$Credential
    )
    process {
        foreach ($computer in $ComputerName) {
            $result = [PSCustomObject]@{
                ComputerName      = $computer
                Ping              = $false
                WinRM             = $false
                CPUPercent        = $null
                FreeMemGB         = $null
                FreeDiskC         = $null
                CriticalServicesOK = $false
            }

            $result.Ping = Test-Connection $computer -Count 1 -Quiet -ErrorAction SilentlyContinue

            if ($result.Ping) {
                $wmiParams = @{ ComputerName = $computer; ErrorAction = "SilentlyContinue" }
                if ($Credential) { $wmiParams.Credential = $Credential }

                try {
                    $os = Get-WmiObject Win32_OperatingSystem @wmiParams
                    if ($os) {
                        $result.WinRM = $true
                        $result.FreeMemGB = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
                    }

                    $cpu = Get-WmiObject Win32_Processor @wmiParams
                    if ($cpu) {
                        $result.CPUPercent = ($cpu | Measure-Object -Property LoadPercentage -Average).Average
                    }

                    $disk = Get-WmiObject Win32_LogicalDisk -Filter "DeviceID='C:'" @wmiParams
                    if ($disk) {
                        $result.FreeDiskC = "{0:N1} GB" -f ($disk.FreeSpace / 1GB)
                    }

                    $critSvcs = @("W3SVC","WinRM","EventLog") | Where-Object {
                        (Get-WmiObject Win32_Service -Filter "Name='$_'" @wmiParams).State -ne "Running"
                    }
                    $result.CriticalServicesOK = $critSvcs.Count -eq 0
                }
                catch {
                    Write-Warning "WMI query failed for $computer`: $_"
                }
            }
            $result
        }
    }
}



function Get-DiskSpaceSummary {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [string[]]$ComputerName
    )
    process {
        foreach ($computer in $ComputerName) {
            Get-WmiObject Win32_LogicalDisk -ComputerName $computer -Filter "DriveType=3" -ErrorAction SilentlyContinue |
                Select-Object `
                    @{N="Server"; E={$computer}},
                    DeviceID,
                    @{N="TotalGB";  E={[math]::Round($_.Size / 1GB, 1)}},
                    @{N="UsedGB";   E={[math]::Round(($_.Size - $_.FreeSpace) / 1GB, 1)}},
                    @{N="FreeGB";   E={[math]::Round($_.FreeSpace / 1GB, 1)}},
                    @{N="PercentFree"; E={[math]::Round($_.FreeSpace / $_.Size * 100, 1)}}
        }
    }
}

# Control what consumers of this module can see
Export-ModuleMember -Function Test-ServerHealth, Get-DiskSpaceSummary
'@

$psm1Content | Out-File "C:PowerShellModulesServerAdminServerAdmin.psm1" -Encoding UTF8

Step 4: Create Private Helper Functions

# Create the private helpers file (not exported — only available within the module)
$helpersContent = @'
# PrivateHelpers.ps1
# Internal helper functions - NOT exported, NOT available to module consumers

function ConvertTo-ByteString {
    param([long]$Bytes)
    switch ($Bytes) {
        { $_ -ge 1TB } { return "{0:N2} TB" -f ($_ / 1TB) }
        { $_ -ge 1GB } { return "{0:N2} GB" -f ($_ / 1GB) }
        { $_ -ge 1MB } { return "{0:N2} MB" -f ($_ / 1MB) }
        { $_ -ge 1KB } { return "{0:N2} KB" -f ($_ / 1KB) }
        default        { return "$_ Bytes" }
    }
}

function Write-ModuleVerbose {
    param([string]$Message)
    Write-Verbose "[ServerAdmin] $Message"
}
'@

$helpersContent | Out-File "C:PowerShellModulesServerAdminPrivateHelpers.ps1" -Encoding UTF8

Step 5: Create the Module Manifest

# Create a full module manifest with metadata and version
New-ModuleManifest `
    -Path "C:PowerShellModulesServerAdminServerAdmin.psd1" `
    -RootModule "ServerAdmin.psm1" `
    -ModuleVersion "2.0.0" `
    -Guid ([guid]::NewGuid().ToString()) `
    -Author "IT Operations" `
    -CompanyName "YourOrganization" `
    -Description "Server administration utility functions for Windows Server 2012 R2" `
    -PowerShellVersion "4.0" `
    -ClrVersion "4.0" `
    -FunctionsToExport @("Test-ServerHealth","Get-DiskSpaceSummary") `
    -VariablesToExport @() `
    -AliasesToExport @() `
    -CmdletsToExport @() `
    -Tags @("Server","Administration","Health","Disk") `
    -ReleaseNotes "v2.0.0: Added Test-ServerHealth function; improved error handling in Get-DiskSpaceSummary"

# Validate the manifest
$manifest = Test-ModuleManifest "C:PowerShellModulesServerAdminServerAdmin.psd1"
Write-Host "Module: $($manifest.Name) v$($manifest.Version)"
Write-Host "Exported functions: $($manifest.ExportedFunctions.Keys -join ', ')"

Step 6: Import and Test the Module

# Import the module explicitly (or let autoloading handle it)
Import-Module ServerAdmin -Verbose

# Check imported commands
Get-Command -Module ServerAdmin

# Test the functions
Test-ServerHealth -ComputerName $env:COMPUTERNAME | Format-List

Get-DiskSpaceSummary -ComputerName $env:COMPUTERNAME | Format-Table -AutoSize

# View help for a function
Get-Help Test-ServerHealth -Full
Get-Help Test-ServerHealth -Examples

# Reload after making changes to .psm1
Remove-Module ServerAdmin -Force
Import-Module ServerAdmin -Force

Step 7: Version a Module and Maintain Multiple Versions

# PowerShell supports side-by-side module versions via version-named subdirectories
# Structure: ModuleNameModuleName.psm1

# Create version 2.0.0 in versioned directory
$versionedPath = "C:PowerShellModulesServerAdmin2.0.0"
New-Item -Path $versionedPath -ItemType Directory -Force

# Move files to versioned directory
Copy-Item "C:PowerShellModulesServerAdminServerAdmin.psm1" "$versionedPath"
Copy-Item "C:PowerShellModulesServerAdminServerAdmin.psd1" "$versionedPath"
New-Item -Path "$versionedPathPrivate" -ItemType Directory -Force
Copy-Item "C:PowerShellModulesServerAdminPrivateHelpers.ps1" "$versionedPathPrivate"

# List available versions
Get-Module -ListAvailable ServerAdmin | Select-Object Name, Version, Path

# Import a specific version
Import-Module ServerAdmin -RequiredVersion "2.0.0"

# Import minimum version
Import-Module ServerAdmin -MinimumVersion "2.0.0"

Step 8: Deploy the Module to Multiple Servers

# Deploy module from a central share to multiple servers
$servers = @("WebServer01","WebServer02","AppServer01","AppServer02")
$moduleSource = "C:PowerShellModulesServerAdmin"
$moduleDestination = "C:WindowsSystem32WindowsPowerShellv1.0ModulesServerAdmin"

Invoke-Command -ComputerName $servers -ScriptBlock {
    param($Source, $Dest)
    New-Item -Path $Dest -ItemType Directory -Force | Out-Null
} -ArgumentList $moduleSource, $moduleDestination

foreach ($server in $servers) {
    $destPath = "\$serverC$WindowsSystem32WindowsPowerShellv1.0ModulesServerAdmin"
    Copy-Item -Path $moduleSource -Destination $destPath -Recurse -Force
    Write-Host "Deployed ServerAdmin module to $server"
}

# Verify deployment
Invoke-Command -ComputerName $servers -ScriptBlock {
    $m = Get-Module -ListAvailable ServerAdmin
    if ($m) { "Module v$($m.Version) installed on $env:COMPUTERNAME" }
    else { "Module NOT found on $env:COMPUTERNAME" }
}

Step 9: Use a Module Autoload Profile for Consistent Sessions

# Create or update the PowerShell profile to autoload modules for all users
# Profile locations (in order of precedence):
# $PSHOMEMicrosoft.PowerShell_profile.ps1       <- All users, all hosts (system)
# $PSHOMEMicrosoft.PowerShellISE_profile.ps1    <- All users, ISE
# $profile.AllUsersAllHosts                      <- Current host variable
# $profile.CurrentUserAllHosts                   <- Current user, all hosts

# Create the all-users profile for all PowerShell hosts
$profilePath = "$PSHOMEMicrosoft.PowerShell_profile.ps1"

$profileContent = @'
# Corporate PowerShell Profile — loaded for all users on all PS hosts
# Do NOT add interactive or slow operations here

# Load corporate administration modules
Import-Module ServerAdmin -ErrorAction SilentlyContinue

# Set useful defaults
$ErrorActionPreference = "Continue"
$PSDefaultParameterValues["*:Encoding"] = "UTF8"

# Set location to scripts directory if it exists
if (Test-Path C:Scripts) { Set-Location C:Scripts }
'@

# Only overwrite if no profile exists yet; otherwise append
if (-not (Test-Path $profilePath)) {
    $profileContent | Out-File $profilePath -Encoding UTF8
    Write-Host "Created system profile at $profilePath"
} else {
    Write-Host "Profile already exists at $profilePath — review manually before adding module autoloads"
}

Step 10: Document Module Changes with a Release Notes File

# Create a CHANGELOG.md file alongside the module
$changelog = @'
# ServerAdmin Module — Change Log

## [2.0.0] - 2024-01-15
### Added
- Test-ServerHealth: Tests ping, WinRM, CPU, memory, disk, and critical service status
- Get-DiskSpaceSummary: Reports fixed-disk space across multiple servers

### Changed
- Improved error handling in all functions (ErrorAction SilentlyContinue on WMI queries)
- Added Credential parameter to Test-ServerHealth for cross-domain queries

## [1.0.0] - 2023-10-01
### Added
- Initial release with Get-DiskSpaceSummary function
'@

$changelog | Out-File "C:PowerShellModulesServerAdminCHANGELOG.md" -Encoding UTF8

# Generate simple module documentation report
$module = Get-Module -ListAvailable ServerAdmin | Select-Object -First 1
$exportedFunctions = (Import-Module ServerAdmin -PassThru).ExportedFunctions.Keys

Write-Host "=== Module Documentation Report ==="
Write-Host "Module : $($module.Name)"
Write-Host "Version: $($module.Version)"
Write-Host "Exported Functions:"
foreach ($fn in $exportedFunctions) {
    $help = Get-Help $fn -ErrorAction SilentlyContinue
    Write-Host "  - $fn`: $($help.Synopsis)"
}

Summary

Building PowerShell script modules on Windows Server 2012 R2 transforms ad-hoc scripts into organized, versioned, and discoverable libraries that the entire IT operations team can rely on. The combination of a well-structured .psm1 file, a module manifest for versioning and metadata, Comment-Based Help for every exported function, and a Private directory for internal helpers creates a maintainable module architecture that scales from a handful of functions to hundreds. Deploying modules to the system-wide Windows PowerShell Modules directory on all servers — or to a central file share added to PSModulePath via Group Policy — ensures consistent tooling across the environment, so every administrator and every automated script has access to the same, tested, version-controlled function library.