This guide assumes you already have a working Gitea instance (1.17 or later that's when the Package Registry shipped) and that you can log in as a user with package write permissions on a project.

If you're looking for the GitLab equivalent, see the Gitlab Powershell repository post. The pattern is identical; only the URLs and tokens change.

PSResourceGet vs PowerShellGet v2: this post uses the modern Register-PSResourceRepository / Publish-PSResource / Find-PSResource / Install-PSResource cmdlets from Microsoft.PowerShell.PSResourceGet. PowerShell 7.4+ ships the module in the box; on 5.1/7.2+ install it with Install-Module -Name Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force.

Why Gitea Specifically

GitLab is the default answer for "private PowerShell module registry on infrastructure I already run". It also wants 4 GB of RAM, a Postgres instance, a Redis instance, and Sidekiq workers. For an organisation that already runs GitLab for source control, that overhead is paid for; for a small team or homelab where the only reason to spin GitLab up would be to host a NuGet feed, it's an order of magnitude more cost than the problem deserves.

Gitea is the same idea minus the weight. A single Go binary, a SQLite or Postgres backend, ~200 MB of RAM idle, and the same NuGet-compatible Package Registry GitLab ships. PSResourceGet doesn't know or care which one is on the other end of the URL. The choice is purely operational:

  • You already run GitLab. Use it. The GitLab post is the same content.
  • You run Gitea, or you run nothing yet. Use Gitea. The build below is what you read.
  • You can't run a server at all. Use a file share; same PSResourceGet semantics, no infrastructure.

The rest of this post is the second case the entire setup of a private PowerShell module registry on Gitea, end to end, on hardware small enough to live on a Raspberry Pi.

Project Settings

The Package Registry is enabled per-repository. Open the repo in the Gitea UI and go to Settings -> Advanced Settings, then make sure "Packages" is checked under "Enable Repository Units".

If you want unauthenticated reads (i.e. anyone in your org can Find-PSResource and Install-PSResource without a token), set the repository Visibility to Public. Private repositories require a token for both read and write that's usually what you want anyway.

Gitea uses a single Package Registry per owner (user or organization), not per repository. Once enabled on any repo owned by myorg, the URL …/api/packages/myorg/nuget/index.json works for that whole owner's packages.

Create a Token

Powershell's Publish-PSResource and Find-PSResource need an API token. Tokens in Gitea live under your user profile, not the repo:

User Settings -> Applications -> Generate New Token

Give it:

  • Name: something descriptive like psgallery-publish.
  • Scopes: write:package and read:package (the minimum for publishing and listing).

Save the token in your secrets manager. Gitea shows it exactly once. Re-issuing one is cheap, but you'll lose any automation that hard-coded it.

Register the Repository

The Gitea Package Registry NuGet endpoint follows this format:

https://gitea.example.com/api/packages/<owner>/nuget/index.json

Replace <owner> with the username or organization that owns the repo. For an organization called ops:

$source = 'https://gitea.example.com/api/packages/ops/nuget/index.json'

$repoParams = @{
    Name    = 'gitea'
    Uri     = $source
    Trusted = $true
}

Register-PSResourceRepository @repoParams

Verify it's registered:

Get-PSResourceRepository -Name 'gitea'

You should see it listed with Trusted = True and the matching URI.

Publish a Module

Point Publish-PSResource at the new repository and authenticate with your token via -ApiKey. Publish-PSResource takes a -Path (the folder containing the .psd1), not a module name:

$publishParams = @{
    Path       = '.\MyModule'
    Repository = 'gitea'
    ApiKey     = '<Your Token>'
}

Publish-PSResource @publishParams

For a binary module built with the C# module pipeline, publish the staged version folder instead:

$publishParams = @{
    Path       = "./out/MyModule/$version"
    Repository = 'gitea'
    ApiKey     = $env:GITEA_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

Find-PSResource -Repository 'gitea' -Name 'MyModule'

If the registry is private, the same call needs the token for read access:

$cred = [pscredential]::new(
    'any-username',
    (ConvertTo-SecureString $env:GITEA_TOKEN -AsPlainText -Force)
)
Find-PSResource -Repository 'gitea' -Name 'MyModule' -Credential $cred

The username field is ignored on token-based auth only the password (the token) is checked. Pass any non-empty string.

Install a Module

Install-PSResource -Name 'MyModule' -Repository 'gitea'

For private registries, same pattern with -Credential. 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 'gitea' -Version '[1.4.7]'

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

Save-PSResource -Name 'MyModule' -Repository 'gitea' -Path 'D:\transfer\modules'

Pushing the Raw .nupkg

Publish-PSResource uses NuGet under the hood, but if you already have a packed .nupkg (e.g. from a dotnet pack step in CI), push it directly with curl:

curl --user "any-username:$GITEA_TOKEN" \
     --upload-file "MyModule.1.4.7.nupkg" \
     "https://gitea.example.com/api/packages/ops/nuget"

This is the path the C# publishing post takes the build pipeline produces a signed nupkg and the publish step is one curl.

A Minimal Gitea Actions / Drone Job

# .gitea/workflows/publish.yml
name: publish-module
on:
  push:
    tags: ['v*']

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Stage module
        shell: pwsh
        run: ./build/Stage-Module.ps1 -Version "${GITHUB_REF_NAME#v}"

      - name: Publish
        shell: pwsh
        env:
          GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
        run: |
          $src = 'https://gitea.example.com/api/packages/ops/nuget/index.json'
          Register-PSResourceRepository -Name gitea -Uri $src -Trusted
          Publish-PSResource `
            -Path       "./out/MyModule/$($env:GITHUB_REF_NAME.TrimStart('v'))" `
            -Repository gitea `
            -ApiKey     $env:GITEA_TOKEN

Operational Notes

  • Tokens are user-scoped. A token belongs to the user that created it. If that user leaves the org, the token dies with their account. For unattended automation, create a dedicated service account in Gitea and issue tokens against it.
  • The cleanup endpoint exists. DELETE /api/packages/{owner}/nuget/{id}/{version} removes a specific version. Useful when a bad release slips through, but never delete a version that's been installed in production bump and ship a fix instead.
  • Gitea has no built-in retention policy for the Package Registry. If your CI publishes pre-release versions on every PR, set up a periodic cleanup job, or you'll fill the disk.
  • The OpenAPI docs are the authoritative reference for the Package Registry endpoints visit /api/swagger on your Gitea instance.

What to Do Tomorrow Morning

Three follow-ups that turn this from "I built a thing" into "we use the thing":

  1. Publish one real module. Not a hello-world, the most-shared internal helper module on your team. Publish-PSResource it once, then update the team's installation README to use Install-PSResource -Repository gitea. A working concrete example beats five whitepaper paragraphs.
  2. Wire the publish into Gitea Actions. The minimal workflow snippet above auto-publishes on a v* tag. Add it to one repo this week. Manual Publish-PSResource from a laptop is fine for the first module; the second one should never need a human in the loop.
  3. Set up a retention job. Pre-release versions accumulate. A weekly script that deletes *-rc* and *-pre* versions older than 30 days from the package API keeps the registry small and the legitimate releases easy to spot.

If your team eventually outgrows Gitea (you actually need fine-grained per-module ACLs, you publish hundreds of versions a month, you want signed-release vs nightly channels), the GitLab post is the same pattern at higher capacity. If you go the other way (air-gapped, no server at all), a plain file share keeps working without any of this infrastructure.