This guide assumes you already have access to a GitLab project and are running PowerShell 7.4+ (ships Microsoft.PowerShell.PSResourceGet by default) or have installed the module manually on PS 5.1/7.2+.

The PowerShell module distribution problem looks the same in every shop that has more than three engineers writing modules. Someone publishes a useful function. They zip the folder, drop it in a Teams chat, paste a usage example. Six months later three people have slightly different copies of MyOrg.AdHelpers on their machines, the bug fix in version 4 only ever made it to two of them, and nobody remembers which one is "current".

The PowerShell Gallery solves this for public modules. For internal modules, the answer is a private NuGet feed, and GitLab already has one built in the per-project Package Registry. PSResourceGet talks to it natively. No new infrastructure, no separate auth system, the same project tokens you already use for CI. This post is the six-command setup that gets a private module registry running on a GitLab instance you already have access to.

PSResourceGet vs PowerShellGet v2: this post uses the modern Register-PSResourceRepository / Publish-PSResource / Find-PSResource / Install-PSResource cmdlets from Microsoft.PowerShell.PSResourceGet. The older Register-PSRepository / Publish-Module / Find-Module / Install-Module flow still works against the same endpoint if you need it.

Install PSResourceGet (if needed)

PowerShell 7.4 and later ships Microsoft.PowerShell.PSResourceGet in the box. On older hosts:

Install-Module -Name Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force -AllowClobber
Import-Module -Name Microsoft.PowerShell.PSResourceGet

Confirm:

Get-Command -Module Microsoft.PowerShell.PSResourceGet

Project Settings

If you have not activated the "Package registry" feature for your project in Settings -> General -> Visibility, project features, permissions, you'll receive a 403 Forbidden response. Accessing the package registry via deploy token is not available when external authorization is enabled.

In order to allow anyone to pull from the Package Registry, toggle Allow anyone to pull from Package Registry as shown below.

Gitlab Settings

Create Token

To be able to push items to the registry, an API token must be created. Go to Settings -> Access Tokens and add a new token with api permissions.

Access Tokens

Save the token in a password manager or GitLab CI/CD variables. Consider storing it in Microsoft.PowerShell.SecretManagement so it can be referenced via -CredentialInfo at publish time.

Setup PowerShell Repository

Get Project ID

Go to Settings > General and get the project ID.

Project ID

The repository URL will follow the following format:

"https://gitlab.example.com/api/v4/projects/<Project ID>/packages/nuget/index.json"

In this case our project ID is 54 so the URL will be:

"https://gitlab.example.com/api/v4/projects/54/packages/nuget/index.json"

Register the PS Repository

To make this step work, make sure to allow all to retrieve from the registry as mentioned in the Project Settings section.

Open PowerShell and register the GitLab repository with PSResourceGet:

$repoParams = @{
    Name    = 'gitlab'
    Uri     = 'https://gitlab.example.com/api/v4/projects/54/packages/nuget/index.json'
    Trusted = $true
}
Register-PSResourceRepository @repoParams

Verify the registration:

Get-PSResourceRepository -Name gitlab

Publish a Module

Now that we have registered the repository, we can publish modules or NuGet packages.

Publish-PSResource takes a -Path (the module folder, not just a name) and an -ApiKey:

$publishParams = @{
    Path       = '.\MyModule'
    Repository = 'gitlab'
    ApiKey     = '<Your Token>'
}
Publish-PSResource @publishParams

For a staged build output (see the C# module publishing post):

$publishParams = @{
    Path       = './out/MyModule/1.4.7'
    Repository = 'gitlab'
    ApiKey     = $env:GITLAB_TOKEN
}
Publish-PSResource @publishParams

-Path must point at the folder containing the .psd1, not at the .psd1 itself and not at a parent folder. This is the most common "why won't it publish" mistake with PSResourceGet.

List All Modules

To list all modules in the registry:

Find-PSResource -Repository "gitlab"

For private registries, authenticate with -Credential:

$cred = [pscredential]::new(
    'any-username',
    (ConvertTo-SecureString $env:GITLAB_TOKEN -AsPlainText -Force)
)
Find-PSResource -Repository "gitlab" -Credential $cred

The username is ignored with token-based auth any non-empty string works.

Install a Module

To install a module from the repository:

Install-PSResource -Name "MyModule" -Repository "gitlab"

Always pin a version on production. PSResourceGet uses NuGet version range syntax, so [1.4.7] means "exactly 1.4.7":

Install-PSResource -Name "MyModule" -Repository "gitlab" -Version "[1.4.7]"

To save a module without installing (useful for air-gapped transfer):

Save-PSResource -Name "MyModule" -Repository "gitlab" -Path "D:\transfer\modules"

Pushing the Raw .nupkg

If you already have a packed .nupkg from a dotnet pack step in CI, push it directly with curl:

curl --header "JOB-TOKEN: $CI_JOB_TOKEN" \
     --upload-file "MyModule.1.4.7.nupkg" \
     "$CI_API_V4_URL/projects/$CI_PROJECT_ID/packages/nuget/"

Where to Take This Next

Three concrete next steps now that the registry exists:

  1. Move one real module to it. Pick the most-shared internal module in your team's repos, the one that gets emailed around. Publish-PSResource it once, then update the README to install it via Install-PSResource. The friction reduction will sell the rest of the team on the pattern within a week.
  2. Wire publishing into CI. Use the .gitea/workflows/publish.yml-style block (works on GitLab CI with a token swap) so a tagged commit auto-publishes a new version. Manual Publish-PSResource from a laptop is fine for the first module; for the second one, automate it.
  3. Pin versions in production. Anywhere a module gets installed by a service account or scheduled task, use -Version '[1.4.7]' not "latest". Install-PSResource follows NuGet range syntax, the same [exact] notation works.

If your environment is Gitea instead of GitLab, the Gitea post is the identical pattern against a different package-registry endpoint. If your environment has neither, a plain file share is the air-gapped version of the same idea, simpler to bootstrap, harder to scale past a small team.