sundaydeploys.dev — bash
  ┌─────────────────────────────────────────┐
  │                                         │
  │    ☽  S U N D A Y   D E P L O Y S  ☽    │
  │                                         │
  │         ~  code · hobbies  ~            │
  │                                         │
  └─────────────────────────────────────────┘
~/sundaydeploys $ cat welcome.txt
I'm a software engineer who spends weekdays writing code and weekends chasing my hobbies

Private Package Marketplace with GitLab

gitlab ci-cd packages

Why I Stopped Copy-Pasting Shared Libraries

I had an internal CLI tool that three projects depended on, and I’d been copying files between repos like it was 2005. GitLab’s generic package registry replaced all of that. It handles versioning, access control, and plugs straight into your CI pipeline. If you’re already on GitLab, you don’t need to stand up Artifactory or Nexus to get it.

This post walks through publishing a generic package from CI, consuming it in another project, and the access control bits that connect the two.

Prerequisites

You’ll need:

  • A GitLab.com account (or self-managed instance running 13.5+) with at least one project
  • Enough familiarity with .gitlab-ci.yml to read a pipeline config
  • Something to publish. A tarball, a binary, a zip file. The generic registry doesn’t care what it is.
  • curl installed locally for the verification steps

The generic package registry is available on all tiers, including free. No feature flags, no admin toggles.

Structure Your Package Project

Create a GitLab project (or use an existing one) that will own and publish the package. This is the project whose registry holds the artifacts.

Keep the repo layout simple. Your source code, build scripts, and the .gitlab-ci.yml all live here. The publishable artifact should be something your build step produces, not something you check into the repo.

Pick a tagging convention for versions. v1.0.0, v1.1.0, etc. Tags will trigger your publish pipeline, and CI_COMMIT_TAG gives you the version string automatically. Semantic versioning works well here, but the registry doesn’t enforce any format. Choose something and stick with it.

Write the CI Publishing Pipeline

Add a publish job to your .gitlab-ci.yml that fires only on tag pushes:

publish:
  stage: deploy
  image: curlimages/curl:latest
  rules:
    - if: $CI_COMMIT_TAG
  script:
    - |
      curl --fail --location \
        --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
        --upload-file path/to/artifact.tar.gz \
        "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/my_package/${CI_COMMIT_TAG}/artifact.tar.gz"      

CI_JOB_TOKEN is injected automatically. No secrets to manage, no tokens to rotate. CI_API_V4_URL and CI_PROJECT_ID are also predefined, so the URL builds itself.

If you’re publishing multiple files under the same package version, upload them one at a time. Concurrent uploads to the same package version cause HTTP 500 errors. Serial only.

One gotcha: by default, re-publishing the same name and version doesn’t overwrite. It adds files alongside the existing ones. If you want strict one-file-per-version behavior, you’ll need to configure duplicate handling at the group level.

Set Up Cross-Project Access

For cross-project consumption (the most common setup), you need to add the consuming project to the publisher’s job token allowlist. Go to the publisher project’s Settings > CI/CD > Job token permissions and add the consuming project’s path. The consuming project’s pipeline user also needs at least Reporter access to the publisher project.

This is the step where most people get stuck. The allowlist tripped me up for longer than I’d like to admit. Without it you’ll get 403s in your consuming pipeline with no obvious explanation.

Consume Packages in Another Project

Downloading is the mirror of uploading. In a consuming project’s pipeline:

download:
  stage: build
  image: curlimages/curl:latest
  script:
    - |
      curl --fail --location \
        --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
        --output artifact.tar.gz \
        "${CI_API_V4_URL}/projects/<PUBLISHER_PROJECT_ID>/packages/generic/my_package/1.0.0/artifact.tar.gz"      

Replace <PUBLISHER_PROJECT_ID> with the numeric ID of the project that published the package. You can find it on the project’s main page in GitLab.

For local development, use a personal access token or deploy token instead:

curl --header "PRIVATE-TOKEN: <your_token>" \
  --location --output artifact.tar.gz \
  "https://gitlab.com/api/v4/projects/<PROJECT_ID>/packages/generic/my_package/1.0.0/artifact.tar.gz"

Pin your consuming pipelines to explicit versions. Don’t build a “latest” convention unless you’ve thought through what happens when a breaking change lands.

Token Types Reference

There are four token types, and picking the wrong one is how you end up debugging 403s for an hour.

  • CI_JOB_TOKEN: automatic in pipelines, scoped to the current project. Permissions match those of the user who triggered the pipeline.
  • Project access tokens: good for service accounts. Require api scope and Developer+ role.
  • Deploy tokens: designed for read-only distribution. Scope them to read_package_registry.
  • Personal access tokens: for local dev work. api scope.

Verify It Works

Run through this checklist to confirm everything is wired up:

  1. Push a tag to the publisher project. Watch the pipeline. The publish job should succeed with a 201 response.
  2. In the GitLab UI, go to the publisher project’s sidebar: Deploy > Package Registry. Your package and version should be listed there.
  3. From your terminal, curl the download URL with a personal access token. You should get the artifact back.
  4. Trigger the consuming project’s pipeline. It should pull the package without errors.

Troubleshooting

HTTP 500 on publish. You’re uploading multiple files to the same package version concurrently. Serialize your upload jobs.

403 on cross-project download. The consuming project isn’t on the publisher’s job token allowlist, or the consuming pipeline’s user doesn’t have Reporter access to the publisher project. Check both.

Duplicate packages piling up. Default behavior is additive. Re-publishing same name/version adds files instead of replacing them. Configure duplicate handling at the group level, or set up cleanup policies (available since GitLab 15.2) to prune old entries automatically.

File too large. Self-managed instances default to a configurable per-file limit for generic packages. On GitLab.com, the upper bound is 5 GB (an S3 single-PUT ceiling). If you’re hitting the limit, check your instance settings or find another way to ship the file.

Good to Know

Job token permissions inherit from the triggering user. If that user has Developer or higher access, the token can list and manage packages too. If you’re getting permission errors, check the triggering user’s role on the target project.

Generic package uploads and downloads are project-scoped. Unlike npm or Maven registries in GitLab, there’s no group-level URL for publishing or pulling generic packages. You can list packages across projects using the group-level Packages API (GET /groups/:id/packages), but each project’s registry is its own namespace for the actual artifacts.

What You’ve Got Now

You now have a versioned, access-controlled package registry running inside GitLab without standing up anything new. Tags trigger publishes, consuming projects pull pinned versions through CI, and the allowlist keeps everything locked down.

If you want to go further, look into GitLab’s language-specific registries (npm, Maven, PyPI) for packages where native tooling makes life easier, or set up cleanup policies to keep old versions from piling up.