Category: DevOps
Excerpt: Build a private PowerShell repository using Azure Artifacts with automated CI/CD publishing, semantic versioning, code signing, and JEA integration for secure enterprise module distribution.
You have a PowerShell module that started as a helpful script and then became business-critical. Now every admin workstation, build agent, and jump box needs the same version at the same time.
Copying module folders to file shares works for a while. But file shares do not give you clean version history, feed permissions, pipeline publishing, or a nice Install-PSResource experience. Azure Artifacts does.
In this tutorial, you will build a private NuGet feed for enterprise PowerShell modules, publish modules from Azure DevOps, stamp them with GitVersion, sign script files using an Azure Key Vault-backed signing workflow, and expose the module through Just-Enough-Administration (JEA).

Prerequisites
To follow along, you will need the following:
-
An Azure DevOps organization and project.
-
Permission to create or administer an Azure Artifacts feed.
-
A PowerShell module with a
.psd1manifest. -
PowerShell 7.x with the
Microsoft.PowerShell.PSResourceGetmodule available. -
An Azure Key Vault certificate or signing process approved by your security team.
-
A Windows host if you plan to configure JEA endpoints.
This tutorial uses placeholders such as <ORGANIZATION_NAME>, <PROJECT_NAME>, and <FEED_NAME>. Replace those values with your environment names before running the commands.
Creating an Azure Artifacts Feed for PowerShell Modules
Azure Artifacts feeds can store NuGet packages, and PowerShell modules published with PSResourceGet use a NuGet-based repository model. That combination makes Azure Artifacts a solid private PowerShell repository for internal modules.
In Azure DevOps, open Artifacts, create a new feed, and choose the visibility you want. For a central enterprise module feed, start with project-scoped permissions unless you intentionally need the feed available across the entire organization.
After creating the feed, open Connect to feed, choose NuGet.exe, and copy the v3 feed URL. The project-scoped URL uses this format:
“`plain text
https://pkgs.dev.azure.com/
An organization-scoped feed uses this format: ```plain text https://pkgs.dev.azure.com/<ORGANIZATION_NAME>/_packaging/<FEED_NAME>/nuget/v3/index.json
Keep that URL handy. You will register it as a PowerShell repository next.
Registering the Feed with PSResourceGet
PSResourceGet replaces the older PowerShellGet v2 publishing and installation cmdlets with commands such as Register-PSResourceRepository, Publish-PSResource, and Install-PSResource.
On an administrator workstation, register the Azure Artifacts feed as a trusted repository. If you have already configured a compatible NuGet credential provider or your shell is authenticated through your enterprise process, the repository registration is only the feed metadata.
Register-PSResourceRepository -Name 'EnterpriseModules' `
-Uri 'https://pkgs.dev.azure.com/<ORGANIZATION_NAME>/<PROJECT_NAME>/_packaging/<FEED_NAME>/nuget/v3/index.json' `
-Trusted
If your organization stores feed credentials in a vault supported by PSResourceGet, register credential information with the repository. Microsoft’s PSResourceGet examples show the PSCredentialInfo constructor with a vault name and secret name.
$credentialInfo = [Microsoft.PowerShell.PSResourceGet.UtilClasses.PSCredentialInfo]::new(
'SecretStore',
'EnterpriseModulesFeed'
)
Register-PSResourceRepository -Name 'EnterpriseModules' `
-Uri 'https://pkgs.dev.azure.com/<ORGANIZATION_NAME>/<PROJECT_NAME>/_packaging/<FEED_NAME>/nuget/v3/index.json' `
-Trusted `
-CredentialInfo $credentialInfo `
-Force
Verify the repository registration.
Get-PSResourceRepository -Name 'EnterpriseModules'
Installing from the feed is now a normal PowerShell resource operation.
Install-PSResource -Name 'Contoso.Operations' -Repository 'EnterpriseModules'

Preparing the Module Manifest for CI/CD
Your module manifest is the contract between your code and the package consumers. At minimum, confirm the manifest has a module version, root module, author, description, exported functions, and compatible PowerShell edition details.
A simple module layout might look like this:
“`plain text
Contoso.Operations/
├── Contoso.Operations.psd1
├── Contoso.Operations.psm1
├── Public/
│ └── Restart-ContosoService.ps1
└── Private/
└── Get-ContosoServiceState.ps1
Before packaging, validate the manifest locally.
Test-ModuleManifest -Path .\Contoso.Operations\Contoso.Operations.psd1
Do not skip this step in the pipeline. A broken manifest turns a clean deployment pipeline into a broken package repository. ## Adding Semantic Versioning with GitVersion Versioning is where many internal module feeds become messy. Someone publishes `1.0.0`, someone else overwrites a folder, and soon nobody knows what is installed where. GitVersion calculates semantic versions from your Git history. In Azure DevOps, GitVersion’s current guidance calls out an important setting: disable shallow fetch by setting `fetchDepth: 0`, or GitVersion can fail because the pipeline did not clone enough history. Add a `GitVersion.yml` file to the repository root.
mode: ContinuousDelivery
branches:
main:
regex: ^main$
increment: Patch
pull-request:
regex: ^(pull|pull-requests|pr)[/-]
increment: Inherit
The exact branching rules are up to your release process. The goal is to have the pipeline calculate the version and write it into the module manifest before publishing.  ## Signing Module Scripts with Azure Key Vault Code signing gives consumers confidence that the module files came from your pipeline and were not modified after publishing. For enterprise modules, sign the `.ps1`, `.psm1`, and other script files before calling `Publish-PSResource`. One practical option is AzureSignTool, which signs Authenticode content using a certificate in Azure Key Vault. The project documents installing the tool as a .NET global tool with an exact version.
dotnet tool install –global –version 7.0.0 AzureSignTool
A signing step can sign all PowerShell script files in the module folder. The following example uses managed identity authentication, which avoids placing an application secret in the pipeline. Run it on an agent identity that has the required Key Vault certificate access.
$files = Get-ChildItem -Path ‘.\Contoso.Operations’ -Include ‘.ps1′,’.psm1′ -Recurse
foreach ($file in $files) {
AzureSignTool sign -kvu 'https://<KEY_VAULT_NAME>.vault.azure.net/'
-kvc ‘-kvm
-tr ‘http://timestamp.digicert.com’ -td sha384
-fd sha384 -v
$file.FullName
}
If your organization requires a service principal instead of managed identity, AzureSignTool supports client ID, tenant ID, and client secret parameters. Store those values as secret pipeline variables or, better, retrieve them through your approved secret-management process. After signing, verify signatures before publishing.
Get-ChildItem -Path ‘.\Contoso.Operations’ -Include ‘.ps1′,’.psm1′ -Recurse |
Get-AuthenticodeSignature |
Format-Table Path, Status, SignerCertificate -AutoSize
## Publishing from Azure DevOps Now wire the feed, versioning, signing, and publishing together. The `NuGetAuthenticate@1` task configures NuGet tools to authenticate with Azure Artifacts feeds. After that, the pipeline can run PowerShell commands that publish to the feed. Create an `azure-pipelines.yml` file similar to the following.
trigger:
– main
pool:
vmImage: ‘windows-latest’
steps:
– checkout: self
fetchDepth: 0
-
task: [email protected]
displayName: ‘Install GitVersion’
inputs:
versionSpec: ‘6.7.x’ -
task: [email protected]
displayName: ‘Calculate GitVersion’
name: gitversion -
task: NuGetAuthenticate@1
-
pwsh: |
$manifestPath = ‘.\Contoso.Operations\Contoso.Operations.psd1’
Update-ModuleManifest -Path $manifestPath -ModuleVersion ‘$(GitVersion.SemVer)’
Test-ModuleManifest -Path $manifestPath
displayName: ‘Stamp and validate module manifest’ -
pwsh: |
dotnet tool install –global –version 7.0.0 AzureSignTool$files = Get-ChildItem -Path ‘.\Contoso.Operations’ -Include ‘.ps1′,’.psm1′ -Recurse
foreach ($file in $files) {
AzureSignTool sign-kvu 'https://<KEY_VAULT_NAME>.vault.azure.net/'
-kvc ‘‘ -kvm
-tr ‘http://timestamp.digicert.com’-td sha384
-fd sha384-v
$file.FullName
}$badSignature = Get-ChildItem -Path ‘.\Contoso.Operations’ -Include ‘.ps1′,’.psm1′ -Recurse |
Get-AuthenticodeSignature |
Where-Object Status -ne ‘Valid’if ($badSignature) {
$badSignature | Format-Table Path, Status -AutoSize
throw ‘One or more module files are not signed with a valid signature.’
}
displayName: ‘Sign module files’ -
pwsh: |
$pat = $env:SYSTEM_ACCESSTOKEN
$securePat = ConvertTo-SecureString $pat -AsPlainText -Force
$credential = [pscredential]::new(‘AzureDevOps’, $securePat)Register-PSResourceRepository -Name ‘EnterpriseModules’
-Uri 'https://pkgs.dev.azure.com/<ORGANIZATION_NAME>/<PROJECT_NAME>/_packaging/<FEED_NAME>/nuget/v3/index.json'
-Trusted `
-ForcePublish-PSResource -Path ‘.\Contoso.Operations’
-Repository 'EnterpriseModules'
-Credential $credential `
-ApiKey ‘AzureArtifacts’
displayName: ‘Publish module to Azure Artifacts’
env:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
Two details matter in this pipeline. First, `fetchDepth: 0` gives GitVersion enough Git history. Second, the `-ApiKey` value for Azure Artifacts can be any non-empty string when authentication is already handled; Microsoft’s Azure Artifacts walkthrough shows `Publish-PSResource` using `-ApiKey <ANY_STRING>`.  ## Installing the Module on Servers Once the pipeline publishes the package, servers and admin workstations install the module from the private feed.
Register-PSResourceRepository -Name ‘EnterpriseModules’ -Uri 'https://pkgs.dev.azure.com/<ORGANIZATION_NAME>/<PROJECT_NAME>/_packaging/<FEED_NAME>/nuget/v3/index.json'
-Trusted
Install-PSResource -Name ‘Contoso.Operations’ -Repository ‘EnterpriseModules’ -Scope AllUsers
For locked-down servers, put this registration into your image build, DSC configuration, Intune remediation, or configuration-management baseline rather than asking admins to register repositories manually. ## Restricting Module Actions with JEA Publishing a module securely is only half the story. You still need to control who can run privileged functions from that module. JEA lets you expose a constrained PowerShell endpoint with only the commands a role needs. Create a role capability file that imports your module and exposes only the approved function.
$roleParameters = @{
Path = ‘C:\Program Files\WindowsPowerShell\Modules\Contoso.Operations\RoleCapabilities\ServiceOperator.psrc’
Author = ‘Contoso IT’
CompanyName = ‘Contoso’
Description = ‘Allows service operators to restart approved Contoso services.’
ModulesToImport = ‘Contoso.Operations’
VisibleFunctions = ‘Restart-ContosoService’
}
New-PSRoleCapabilityFile @roleParameters
Next, create and register a session configuration that maps an Active Directory group to the role capability.
$roleDefinitions = @{
‘CONTOSO\Service Operators’ = @{ RoleCapabilities = ‘ServiceOperator’ }
}
New-PSSessionConfigurationFile -Path ‘C:\JEA\ContosoService.pssc’ -SessionType RestrictedRemoteServer
-RunAsVirtualAccount `
-RoleDefinitions $roleDefinitions
Register-PSSessionConfiguration -Name ‘ContosoService’ -Path 'C:\JEA\ContosoService.pssc'
-Force
Operators connect to the endpoint instead of receiving full administrative access.
Enter-PSSession -ComputerName ‘SERVER01’ -ConfigurationName ‘ContosoService’
“`
Now your package feed controls distribution, code signing protects integrity, and JEA controls runtime privilege.
Operational Tips for Enterprise Module Distribution
Keep the feed boring and predictable. These practices prevent most module-distribution headaches:
-
Publish only from CI/CD. Do not let humans publish production module versions from workstations.
-
Treat the module manifest as a build artifact. Stamp it, validate it, and publish that exact folder.
-
Use semantic versions and never overwrite released versions.
-
Require valid Authenticode signatures before publishing.
-
Give feed readers broad access, but feed publishers limited access.
-
Separate development, test, and production feeds if release approvals matter.
-
Add JEA endpoints for privileged functions instead of granting broad local administrator rights.
A private PowerShell repository is not just a package bucket. Used well, it becomes the control plane for how your automation reaches the enterprise.
Conclusion
You built a private PowerShell module distribution flow using Azure Artifacts and PSResourceGet. You created a NuGet feed, registered it as a PowerShell repository, published modules through Azure DevOps, stamped versions with GitVersion, signed files with an Azure Key Vault-backed signing workflow, and restricted privileged operations with JEA.
The result is a repeatable pattern: developers commit module code, the pipeline validates and signs it, Azure Artifacts stores the versioned package, and servers install only approved releases.
Sources
-
https://learn.microsoft.com/en-us/azure/devops/artifacts/tutorials/private-powershell-library?view=azure-devops
-
https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.psresourceget/register-psresourcerepository?view=powershellget-3.x
-
https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.psresourceget/publish-psresource?view=powershellget-3.x
-
https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/reference/nuget-authenticate-v1?view=azure-pipelines
-
https://gitversion.net/docs/reference/build-servers/azure-devops
-
https://github.com/vcsjones/AzureSignTool/blob/v7.0.0/README.md
-
https://learn.microsoft.com/en-us/powershell/scripting/security/remoting/jea/overview?view=powershell-7.5