The generic packages registry accepts any file at a URL built from the project ID, package name, version and filename. Publishing is an upload request with a token header, which makes it the simplest way to give a build artifact a permanent, versioned address.
Most package registries want a specific ecosystem — npm, Maven, PyPI. The generic registry does not care what your file is. A compiled binary, a firmware image, a tarball, a signed installer: all the same to it. That makes it a good fit for the artifacts that do not belong to any package manager, which in most organisations is quite a lot of them.
Table of contents
- The upload request
- Publishing from a pipeline
- Listing, downloading and cleaning up
- When a registry is the right tool, and when it is not
- How this fits the rest of the stack
- FAQ
The upload request
The URL encodes everything about the package, and the upload is a PUT of the file to it.
curl --location --header "PRIVATE-TOKEN: <personal_access_token>" \
--upload-file path/to/file.txt \
"https://gitlab.example.com/api/v4/projects/24/packages/generic/my_package/1.0.0/file.txt"
Reading the path: project 24, generic registry, package my_package, version 1.0.0, filename file.txt. Change the version and you get a new version rather than overwriting the old one, which is the behaviour you want.
Four authentication headers are accepted, and which one you use should depend on where the request comes from:
# A person, from a laptop
--header "PRIVATE-TOKEN: <personal_access_token>"
# A project-owned credential
--header "PRIVATE-TOKEN: <project_access_token>"
# A deploy token
--header "DEPLOY-TOKEN: <deploy_token>"
# Inside CI -- the right answer for pipelines
--header "JOB-TOKEN: $CI_JOB_TOKEN"
In a pipeline, use the job token. It exists automatically, is scoped to that job, and expires when the job ends, so there is no credential to store, rotate, or leak. Using a personal token in CI is the habit that generates expiry incidents later.
The --location flag matters more than it looks: without it, curl does not follow the redirect the API issues, and the upload silently goes nowhere while returning a success-looking response.
Publishing from a pipeline
The predefined variables mean the URL can be built entirely from context, so the job works unchanged in a fork or a renamed project:
publish:
stage: deploy
rules:
- if: $CI_COMMIT_TAG
script:
- |
curl --fail --location \
--header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
--upload-file build/app-linux-amd64 \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/app/${CI_COMMIT_TAG}/app-linux-amd64"
Two details worth copying. The rule means this only runs on tagged commits, so your registry fills with releases rather than with every commit to every branch. And --fail makes curl exit non-zero on an HTTP error — without it, a 401 returns an error body, curl exits successfully, and your pipeline reports a green publish that uploaded nothing.
For several files, loop rather than repeating the command:
upload_all:
stage: publish
script:
- |
for file in ./build/*; do
[ -f "$file" ] || continue
name=$(basename "$file")
curl --fail --header "JOB-TOKEN: $CI_JOB_TOKEN" \
--upload-file "$file" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/app/${CI_COMMIT_TAG}/${name}"
echo "Uploaded: $name"
done
Publish a checksum file alongside the binary. It costs one line and it lets whoever downloads it verify they got what you built.
Listing, downloading and cleaning up
The management side of the API is conventional REST:
# What is in this project?
curl --header "PRIVATE-TOKEN: <token>" \
--url "https://gitlab.example.com/api/v4/projects/:id/packages"
# Across a group
curl --header "PRIVATE-TOKEN: <token>" \
--url "https://gitlab.example.com/api/v4/groups/:id/packages?exclude_subgroups=false"
# The files inside one package
curl --header "PRIVATE-TOKEN: <token>" \
--url "https://gitlab.example.com/api/v4/projects/:id/packages/:package_id/package_files"
# Delete a package
curl --request DELETE --header "PRIVATE-TOKEN: <token>" \
--url "https://gitlab.example.com/api/v4/projects/:id/packages/:package_id"
Downloading is a GET on the same URL you uploaded to, which means a deploy script can fetch a specific version by name without a lookup:
curl --fail --location \
--header "JOB-TOKEN: $CI_JOB_TOKEN" \
--output app \
"${CI_API_V4_URL}/projects/42/packages/generic/app/1.4.2/app-linux-amd64"
chmod +x app
Cleanup deserves a policy rather than good intentions. Registries grow without limit because deleting things feels risky, and storage costs and backup times grow with them. A reasonable default: keep all tagged releases, keep the last handful of pre-release builds, delete the rest on a schedule. Automate it, because nobody does it manually twice.
When a registry is the right tool, and when it is not
The generic registry is a good answer for artifacts that outlive a pipeline and are consumed by something other than the pipeline that made them.
- Good: release binaries for several platforms, firmware images, signed installers, generated SDKs, database seed files, large test fixtures that do not belong in git.
- Less good: anything a real package manager already handles — publish npm packages to the npm registry, Python packages to PyPI, container images to a container registry. Ecosystem tooling gives you dependency resolution and version constraints that a generic file store cannot.
- Wrong: intermediate build outputs consumed by a later stage of the same pipeline. Job artifacts are designed for that and expire automatically. A registry entry for every intermediate step is a registry you will have to clean up.
The distinction that matters is lifetime. Job artifacts are scoped to a pipeline and expire. Packages are permanent and versioned. If the file needs to be retrievable in a year to reproduce a release, it belongs in the registry; if it only needs to survive until the next stage runs, it does not.
One thing to design in from the start: an immutable version scheme. Overwriting latest in place makes it impossible to answer what was actually deployed on a given date, which is the question you will eventually need answered under pressure. Version by tag, and if you want a moving pointer, publish it as an additional file rather than by mutating an existing one.
How this fits the rest of the stack
A versioned artifact with a permanent address is half of a reproducible deploy. The other half is a runtime that pulls a specific version and can go back to the previous one when the new one misbehaves.
RunxBuild handles that half. A web service in Node, Next.js, Python, Go, Ruby, Java, .NET or Docker builds from your GitHub repository, gets a live route, and keeps deploy history so a rollback is selecting the previous deploy rather than reconstructing it. Build logs and runtime logs sit together, so a release that publishes cleanly and then fails on startup is visible in one place. Managed MySQL and Postgres run alongside on private networking, with persistent storage attached to the service for the artifacts that need to stay. To see what a service, its database and its storage add up to, the RunxBuild hosting calculator lists them as separate line items.
Useful related references:
- 400 error code in rest api: The Nine Reasons and the Three That Hide Behind “Bad Request”
- What Does a 400 Bad Request Mean? The Developer’s Working Definition
- n8n HTTP Request Node: The Auth and Error Playbook
- Services on RunxBuild
FAQ
How do I upload a file to the GitLab generic packages registry?
Send an upload request to a URL built from the project ID, package name, version and filename, with an authentication header. In curl that is --upload-file plus --header and the API path under /packages/generic/. Include --location so curl follows the redirect the API issues, or the upload silently goes nowhere.
Which token should I use to publish from CI?
The CI job token, available automatically as a predefined variable and passed in a JOB-TOKEN header. It is scoped to the job and expires when the job ends, so there is nothing to store or rotate. Personal access tokens in pipelines are what cause expiry incidents months later.
Why does my pipeline report success when nothing was uploaded?
Because curl exits zero on HTTP error responses by default — a 401 returns an error body and curl considers its job done. Add --fail so an HTTP error becomes a non-zero exit and fails the job. Add --location too, since a missed redirect produces the same misleading result.
What is the difference between job artifacts and packages?
Lifetime and purpose. Job artifacts are scoped to a pipeline, are meant for passing files between stages, and expire on a schedule. Packages are permanent, versioned, and meant to be consumed by something outside the pipeline that produced them. Intermediate build output belongs in artifacts, not the registry.
Should I publish npm or Python packages to the generic registry?
No. Use the ecosystem-specific registries, which give you dependency resolution, version constraints and standard tooling that a generic file store cannot. The generic registry is for artifacts with no package manager of their own — binaries, firmware images, installers, generated SDKs.