Distributing and Updating PowerShell Modules In-House — PSResourceGet and an Internal Repository

· · PowerShell, Modules, Distribution, Version Management, Operational Improvement, Maintainability, Information Systems, Automation

“I wrote a handy script, so I put it in the shared folder.” From that moment onwards, maintenance debt starts quietly accumulating. Someone copies it, modifies their local version, fixes to the original never reach them, and nobody knows which release is running where. A few years later the shared folder contains aggregate.ps1, aggregate_v2.ps1 and aggregate_fixed_latest.ps1 side by side.

In the PowerShell world, the answer to this problem is clear: make it a module, give it a version, and distribute it from a repository. That alone lets you answer “which release is installed?” and “if I update it, does it reach everyone?”. And from PowerShell 7.4 onwards, the mechanism for doing so (PSResourceGet) is included out of the box.

This article walks through the steps for turning scripts you share internally into a module and standing up an internal repository to distribute and update them, using a realistic setup that requires no dedicated server. For turning functions into modules in the first place, reading “Parameter Design and Modularisation in PowerShell” first should make this quicker to follow.

1. The Bottom Line First

  • PSResourceGet (Microsoft.PowerShell.PSResourceGet) ships with PowerShell 7.4. It coexists with the traditional PowerShellGet 2.2.5, so you can use it without breaking existing scripts. It does not ship with Windows PowerShell 5.1, so both consumers and publishers need to install it beforehand (see the next section).1
  • You can start an internal repository on a file share. Just pass a UNC path to Register-PSResourceRepository. No dedicated server required.2
  • Treat the manifest (.psd1) as mandatory. Without a version number, you can manage neither updates nor isolation of a problem.3
  • Do not use a wildcard for FunctionsToExport — list them explicitly in an array. Command discovery gets faster, and you avoid unintentionally exposing internal functions.3
  • Version with semantic versioning. If you cannot express breaking changes in the major version, consumers cannot update with confidence.4
  • Publish with Publish-PSResource, fetch with Install-PSResource, update with Update-PSResource.56
  • Module search paths differ between 5.1 and 7. Without understanding the difference in $env:PSModulePath, you will hit “I installed it, but it cannot be found”.7
  • If the execution policy is AllSigned, an Authenticode signature on each script file is mandatory. Catalog signing is for verifying package integrity and does not satisfy the execution policy.8
  • Be careful about automatically updating modules that unattended jobs depend on. Verify first, then raise the version on a planned basis — that is the safe model.

2. The Problems With “ps1 Files in a Shared Folder”

First, let us be clear about what we are trying to solve.

Symptom Root cause
Nobody knows which release is running There is no concept of a version number
Fixes do not reach everyone Each person holds their own copy
Nobody knows who is using it No record of retrieval is kept (as discussed below, this one does require a choice of distribution method)
It only breaks in some environments Dependencies (required modules, PS version) are not declared
We want to fix it but cannot judge the impact There is no distinction between published functions and internal ones

Modularisation and repository-based distribution directly address the top four of these. Only “who is using it” depends on the distribution mechanism. The file-share repository introduced in the following sections is convenient, but it keeps no record of who fetched what and when (Get-InstalledPSResource tells you only the state of the machine on which you ran the command). If you want visibility into usage, combine it with one of the following.

3. The Minimal Module Layout

The minimal shape of a distributable module is three things: a folder, a .psm1 and a .psd1.

KsOps\
  KsOps.psd1     ← manifest (version, exported functions, dependencies)
  KsOps.psm1     ← implementation (or dot-sourcing from the Public/Private folders)
  Public\
    Get-KsShareUsage.ps1
    Invoke-KsArchive.ps1
  Private\
    ConvertTo-KsSize.ps1

Generate the manifest template with New-ModuleManifest and fill in what you need.3

$manifest = @{
    Path              = '.\KsOps\KsOps.psd1'
    RootModule        = 'KsOps.psm1'
    ModuleVersion     = '1.0.0'
    GUID              = [guid]::NewGuid().Guid
    Author            = 'IT Department'
    CompanyName       = 'Sample Corporation'
    Description       = 'Shared module for in-house operations scripts (file server inventory and archiving)'
    PowerShellVersion = '5.1'
    CompatiblePSEditions = @('Desktop', 'Core')      # when it is used on both 5.1 and 7
    # Do not use a wildcard. State explicitly only what you publish
    FunctionsToExport = @('Get-KsShareUsage', 'Invoke-KsArchive')
    CmdletsToExport   = @()
    VariablesToExport = @()
    AliasesToExport   = @()
    RequiredModules   = @()                          # declare dependencies here, if any
    Tags              = @('internal', 'operations')
    ProjectUri        = 'https://git.example.co.jp/it/ksops'
}
New-ModuleManifest @manifest

The .psm1 can follow the standard pattern of loading the Public/Private scripts and exporting only the public functions.

# KsOps.psm1
$public  = @(Get-ChildItem -Path "$PSScriptRoot\Public\*.ps1"  -ErrorAction SilentlyContinue)
$private = @(Get-ChildItem -Path "$PSScriptRoot\Private\*.ps1" -ErrorAction SilentlyContinue)

foreach ($file in @($public + $private)) {
    try   { . $file.FullName }
    catch { throw "Failed to load module file: $($file.FullName)$_" }
}

# Publish only the functions under Public (keep this consistent with the manifest declaration)
Export-ModuleMember -Function $public.BaseName

There are two reasons not to use a wildcard for FunctionsToExport. One is command-discovery performance: stating them explicitly lets PowerShell determine “which command lives where” without parsing the module body. The other is a design reason: if internal helpers can be called from outside, they become a de facto public API and you can no longer change them later.3

4. Deciding on a Versioning Scheme

Whether consumers can update with confidence is decided by how you assign version numbers. Adopt semantic versioning (major.minor.patch) and always express breaking changes in the major version.4

Nature of the change What to increment
Renaming a parameter, removing a function, changing the shape of a return value Major (1.2.3 → 2.0.0)
Adding a function or a parameter (existing usage keeps working) Minor (1.2.3 → 1.3.0)
Bug fixes only Patch (1.2.3 → 1.2.4)

When you want to distribute a release for verification, prerelease versions are available. Setting a string such as beta1 in the manifest’s PrivateData.PSData.Prerelease (the hyphen separating it from the version is added automatically, giving 1.3.0-beta1) means it does not come down through a normal fetch and is installed only when you specify -Prerelease explicitly. The string may contain only ASCII alphanumerics and hyphens; periods and + are not allowed.4

On handling breaking changes, the thinking behind interface design is a useful reference (see “DLL and COM Interface Backward Compatibility”).

5. Building an Internal Repository — A File Share Is Enough

PSResourceGet can treat a folder on a file share as a repository.2 This is the lowest-cost setup to adopt.

First, check the prerequisites. It ships with PowerShell 7.4 and later, but is not present in Windows PowerShell 5.1. To use the commands that follow (Register-PSResourceRepository and so on) on 5.1, install the module in advance.1

# [Windows PowerShell 5.1 only] Install PSResourceGet.
# 5.1 and 7 load from different module paths, so run this in the edition you use
if (-not (Get-Module -ListAvailable -Name Microsoft.PowerShell.PSResourceGet)) {
    Install-Module -Name Microsoft.PowerShell.PSResourceGet -Scope AllUsers -Force
}
# [Run once on both the publishing and consuming side] Register the internal repository
# Trusted: it is an in-house distribution, so treat it as trusted / Priority: search it ahead of PSGallery
$repo = @{
    Name     = 'KsInternal'
    Uri      = '\\fileserver\PSRepository'
    Trusted  = $true
    Priority = 10
}
Register-PSResourceRepository @repo

Get-PSResourceRepository | Format-Table Name, Uri, Trusted, Priority

Set the permissions on the shared folder to “write access for the distribution owners only, read-only for consumers”. If this is loose, it becomes a route by which anyone can distribute arbitrary code company-wide. For permission design on shared folders, see “Taking Inventory of a File Server with PowerShell” as well.

For a more serious deployment, register a NuGet-compatible feed such as Azure Artifacts or GitHub Packages. For repositories that require authentication, you can arrange for the credentials to be read from a SecretManagement vault (see “Handling Credentials Safely in PowerShell”).2

6. Publishing, Fetching and Updating

# [Publisher] Publish the module
Publish-PSResource -Path .\KsOps -Repository 'KsInternal'

# [Consumer] Search for it and install it
Find-PSResource -Name 'KsOps' -Repository 'KsInternal'
Install-PSResource -Name 'KsOps' -Repository 'KsInternal' -Scope CurrentUser

# Install a pinned version (recommended for production servers)
Install-PSResource -Name 'KsOps' -Version '1.2.3' -Repository 'KsInternal' -Scope AllUsers

# Update
Update-PSResource -Name 'KsOps' -Repository 'KsInternal'

# Check what is installed (strictly the state of *this* machine)
Get-InstalledPSResource -Name 'KsOps' | Format-Table Name, Version, Repository, InstalledDate

# To know the company-wide installation status, run it on each machine and aggregate
Invoke-Command -ComputerName $servers -ScriptBlock {
    Get-InstalledPSResource -Name 'KsOps' -ErrorAction SilentlyContinue |
        Select-Object Name, Version
} | Sort-Object PSComputerName

Choosing the right -Scope matters. Modules used by unattended Task Scheduler jobs must be installed to AllUsers (or into the service account’s own environment). The classic cause of “it works in my environment but the overnight batch fails with is not recognized as the name of a cmdlet” is an installation into the CurrentUser scope.67

7. Module Search Paths and the 5.1 / 7 Difference

PowerShell looks for modules in the folders listed in $env:PSModulePath. The default paths differ between Windows PowerShell 5.1 and PowerShell 7.7

Edition Default user-scope path
Windows PowerShell 5.1 %USERPROFILE%\Documents\WindowsPowerShell\Modules
PowerShell 7 %USERPROFILE%\Documents\PowerShell\Modules

For modules used on both, declare both Desktop and Core in CompatiblePSEditions and test in both environments before distributing. PSScriptAnalyzer’s PSUseCompatibleSyntax helps with verifying compatibility (see “Protecting PowerShell Script Quality with PSScriptAnalyzer”).

When troubleshooting, check these three things.

$env:PSModulePath -split ';'                       # the search paths
Get-Module -Name KsOps -ListAvailable              # is it found, and which version
(Get-Module KsOps -ListAvailable).ModuleBase       # where it will actually be loaded from

8. Signing and the Execution Policy

There are two mechanisms with different purposes in signing, and confusing them leads to “I signed it, but it will not run”.8

Mechanism What it guarantees Does it satisfy the AllSigned execution policy?
Authenticode signature (Set-AuthenticodeSignature) The publisher and integrity of an individual script file Yes (each file needs a signature)
Catalog signature (New-FileCatalog + signing) The integrity of the module as a whole (the package) No

The execution policy verifies the Authenticode signature on the very .ps1 / .psm1 it is about to load. Signing the catalog file (.cat) leaves the script files inside unsigned, so execution is blocked in an AllSigned environment. If you operate under AllSigned, therefore, signing each file that gets executed is mandatory.

# (1) Mandatory in an AllSigned environment: Authenticode-sign each file that gets executed
Get-ChildItem .\KsOps -Recurse -Include *.ps1, *.psm1, *.psd1 | ForEach-Object {
    Set-AuthenticodeSignature -FilePath $_.FullName -Certificate $cert `
        -TimestampServer 'http://timestamp.digicert.com'
}

# (2) In addition, use a catalog to detect tampering across the whole package
New-FileCatalog -Path .\KsOps -CatalogFilePath .\KsOps\KsOps.cat -CatalogVersion 2
Set-AuthenticodeSignature -FilePath .\KsOps\KsOps.cat -Certificate $cert `
    -TimestampServer 'http://timestamp.digicert.com'

# Verification on the consuming side (separate from the execution policy: is the package intact?)
Test-FileCatalog -Path .\KsOps -CatalogFilePath .\KsOps\KsOps.cat -Detailed

The value of a catalog lies in being able to confirm that “the module set I fetched is identical to what was distributed”, and PSResourceGet has an -AuthenticodeCheck option that verifies signatures and catalogs too.6 Understand the division of responsibility as: Authenticode signatures govern whether it runs, catalogs govern the integrity of the distributed package.

Applying a timestamp means the signature remains valid even after the signing certificate has expired. The full picture of execution policy and signing practice is covered in “PowerShell Execution Policy and Script Signing”.

9. Decisions to Make as Operating Rules

The operating model matters more than the technology. At a minimum, decide the following.

  • Who can publish. Whoever has write permission on the shared folder is someone who can distribute code company-wide
  • Where the change history goes. Record breaking changes in ReleaseNotes (in the manifest’s PrivateData.PSData) or in a CHANGELOG
  • Whether production servers pin versions. For modules that unattended jobs depend on, pinning and updating on a plan is the safe option
  • The deprecation procedure. When removing a function, increment the major version and announce the deprecation in advance
  • Pass tests and lint before publishing. Ideally, publishing happens from CI (see “Testing PowerShell with Pester”)

10. Practical Rules of Thumb (Decision Table)

Question Options Guidance
Distribution format .ps1 in a shared folder / module + repository The dividing line is whether you can manage versions and updates
Module management PowerShellGet 2.x / PSResourceGet Bundled from 7.4 onwards. Use PSResourceGet for anything new1
Repository File share / NuGet-compatible feed Start with a file share. Move to a feed if you need authentication and auditing2
Manifest Omit it / Mandatory Without a version, the operating model does not hold together3
Exported functions '*' / explicit array For discovery performance, and to keep internal functions private3
Install location CurrentUser / AllUsers for unattended execution The criterion is whether the service account can see it7
Updates in production Automatic updates / pinned version + planned updates Prevents the overnight batch from running a new release on its own
Signing None / Authenticode signature on each file (+ catalog) In an AllSigned environment, per-file signing is mandatory. Catalogs are for integrity verification8

11. Summary

  • Distributing .ps1 files via a shared folder makes versions, updates, dependencies and the public surface all unmanageable. Modularisation and repository-based distribution solve it. The one exception is “who is using it”. A file-share repository keeps no record of retrieval, so combine it with read auditing on the shared folder, a feed that provides download statistics, or aggregation of Get-InstalledPSResource across machines.
  • A manifest is mandatory. State FunctionsToExport explicitly as an array and do not publish internal functions.
  • An internal repository can be started simply by registering a file share via a UNC path. Managing write permission is, in practice, the security boundary.
  • Publish with Publish-PSResource, fetch with Install-PSResource, update with Update-PSResource. Install modules used by unattended jobs into the AllUsers scope.
  • The search paths differ between 5.1 and 7. If you support both, declare CompatiblePSEditions and test in both.
  • In an AllSigned environment, each script file needs an Authenticode signature. Catalog signing is for verifying the integrity of the distributed package and is a separate matter from whether it will run. Always apply a timestamp.

Download the Sample Code

The code covered in this article is distributed as a package you can run as-is. It contains a complete module with public and private code separated, and the procedure for distributing it to an internal repository.

Download the sample code (zip)

The samples for this article have been actually executed and verified on PowerShell 7.6 (16 Pester tests). Run the Invoke-SampleTests.ps1 included in the zip to reproduce the same verification on your own machine.

# Syntax parsing + static analysis + Pester tests
./Invoke-SampleTests.ps1

The configuration values (paths, server names, tenant IDs and so on) are examples. Do not run them against production as-is — adapt them to your own environment.

KomuraSoft LLC handles modularising in-house script assets and setting up the infrastructure to distribute them, standardising operations that have become dependent on one person, and improving the maintainability of existing scripts.

References

  1. Microsoft Learn, Package management for PowerShell. On Microsoft.PowerShell.PSResourceGet being the module that replaces PowerShellGet and PackageManagement; on it shipping with PowerShell 7.4 and coexisting with the traditional PowerShellGet 2.2.5; and on it being installable from the PowerShell Gallery on Windows PowerShell 5.1 as well.  2 3

  2. Microsoft Learn, Register-PSResourceRepository. On being able to register a repository by giving -Uri a local folder, a file share (UNC path) or the URL of a NuGet-compatible feed; on marking it trusted with -Trusted; on the search order set by -Priority; and on specifying credentials for repositories that require authentication.  2 3 4

  3. Microsoft Learn, How to write a PowerShell module manifest. On creating a manifest with New-ModuleManifest; on the individual keys such as RootModule, ModuleVersion, GUID, PowerShellVersion, CompatiblePSEditions and RequiredModules; and on why export specifications such as FunctionsToExport should be explicit rather than using wildcards (command-discovery performance).  2 3 4 5 6

  4. Microsoft Learn, Prerelease module versions. On versioning based on semantic versioning; on specifying prerelease versions via PrivateData.PSData.Prerelease; and on prerelease versions not being fetched by default.  2 3

  5. Microsoft Learn, Publish-PSResource. On publishing the module folder given by -Path to a repository; on specifying the destination with -Repository; and on authentication via -ApiKey. 

  6. Microsoft Learn, Install-PSResource. On installing via -Name / -Version / -Repository; on choosing the install location with -Scope (CurrentUser / AllUsers); and on skipping the confirmation with -TrustRepository. See also Update-PSResource for updating.  2 3

  7. Microsoft Learn, about_PSModulePath. On PowerShell searching for modules in the folders listed in $env:PSModulePath, and on the default user-scope and all-users-scope paths differing between Windows PowerShell and PowerShell 7.  2 3 4

  8. Microsoft Learn, New-FileCatalog. On being able to generate a catalog file (.cat) containing hashes of the files under a folder; on signing the catalog with Set-AuthenticodeSignature; and on Test-FileCatalog detecting tampering by comparing the catalog against the files. For the point that the execution policy verifies the Authenticode signature on the script file being executed itself, see about_Execution_Policies (on AllSigned allowing only scripts signed by a trusted publisher to run) and Set-AuthenticodeSignature (on applying an Authenticode signature to a file, and on timestamping via -TimestampServer).  2 3

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

Frequently Asked Questions

Common questions about the topic of this article.

What is the difference between PowerShellGet and PSResourceGet? Which should I use?
PSResourceGet (Microsoft.PowerShell.PSResourceGet) is the new module management mechanism that replaces the older PowerShellGet and PackageManagement, and it ships with PowerShell 7.4 out of the box. It can coexist with the traditional PowerShellGet 2.2.5, so you can migrate without breaking existing scripts. For anything new, PSResourceGet — whose cmdlet names are of the -PSResource form (Install-PSResource, Publish-PSResource and so on) — is the recommended choice. It is also usable on Windows PowerShell 5.1 if you install it from the PowerShell Gallery.
Do I need a dedicated server to stand up an internal repository?
No. The simplest approach is to register a folder on a file share as a repository, which works by passing a UNC path to Register-PSResourceRepository. No dedicated server and no database are required. If your organisation wants access control and auditing, or wants to fetch packages from outside the network, use a NuGet-compatible feed such as Azure Artifacts or GitHub Packages. Starting with a file share and migrating when the need arises is the realistic path.
Is a module manifest (.psd1) really necessary?
In practice, treat it as necessary. A .psm1 on its own can be loaded as a module, but without a manifest it cannot carry a version number, and you lose track of which release is installed where. Without a version you can manage neither updates nor the isolation of a defect when one appears. On top of that, a manifest lets you state which functions are exported, declare dependent modules, and specify the supported PowerShell version and edition. New-ModuleManifest generates a template, so the cost of creating one is trivial.
What is the problem with putting '*' in FunctionsToExport?
Automatic command discovery gets slower, and internal functions you never intended to expose become public. PowerShell needs to know which command lives in which module before loading the module, and with a wildcard it cannot know that without parsing the module body. If you list the exported functions explicitly in an array, that parsing becomes unnecessary. Also, if internal helper functions can be called from outside, they become a de facto public API and you can no longer change them later.
Can the modules I distribute be updated automatically on the consumer's side?
You can update with Update-PSResource, but design automatic updates of modules that business scripts depend on with care. It means your unattended overnight batch will start running on a new version without anyone noticing. In practice, the safe operating model is to verify a new release in a test environment and then update production on a planned basis. If you must automate it, put a brake on it — pin the major version and update within it (specifying a range such as -Version '1.*').

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog